star_gates 0.0.0 → 0.2.0
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.
- checksums.yaml +4 -4
- data/.dictate.toml +19 -0
- data/.pre-commit-config.yaml +20 -0
- data/CHANGELOG.md +60 -3
- data/LICENSE.txt +23 -16
- data/README.md +182 -16
- data/Rakefile +14 -4
- data/lib/star_gates/adapters/base_adapter.rb +65 -0
- data/lib/star_gates/adapters/memory_adapter.rb +93 -0
- data/lib/star_gates/adapters/redis_adapter.rb +119 -0
- data/lib/star_gates/application_star_gate.rb +398 -0
- data/lib/star_gates/circuit.rb +171 -0
- data/lib/star_gates/configuration.rb +172 -0
- data/lib/star_gates/dsl.rb +71 -2
- data/lib/star_gates/errors.rb +8 -0
- data/lib/star_gates/event_store/event_store.rb +79 -0
- data/lib/star_gates/event_store/memory_adapter.rb +78 -0
- data/lib/star_gates/events/base_event.rb +45 -0
- data/lib/star_gates/events/feature_events.rb +97 -0
- data/lib/star_gates/feature.rb +426 -0
- data/lib/star_gates/flags.rb +153 -0
- data/lib/star_gates/gate.rb +100 -0
- data/lib/star_gates/hexagonal/feature_aggregate.rb +250 -0
- data/lib/star_gates/hexagonal/feature_repository.rb +67 -0
- data/lib/star_gates/hexagonal/feature_service.rb +121 -0
- data/lib/star_gates/jsonrpc_rails_adapter.rb +251 -0
- data/lib/star_gates/projections/feature_projection.rb +224 -0
- data/lib/star_gates/registry.rb +58 -0
- data/lib/star_gates/telemetry/opentelemetry_adapter.rb +252 -0
- data/lib/star_gates/telemetry/prometheus_adapter.rb +183 -0
- data/lib/star_gates/telemetry/telemetry_adapter.rb +160 -0
- data/lib/star_gates/version.rb +1 -1
- data/lib/star_gates.rb +308 -8
- metadata +95 -14
- data/sig/star_gates.rbs +0 -4
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../events/feature_events'
|
|
4
|
+
|
|
5
|
+
module StarGates
|
|
6
|
+
module Hexagonal
|
|
7
|
+
class FeatureAggregate
|
|
8
|
+
attr_reader :id, :version, :uncommitted_events
|
|
9
|
+
|
|
10
|
+
def initialize(id, events = [])
|
|
11
|
+
@id = id
|
|
12
|
+
@version = 0
|
|
13
|
+
@uncommitted_events = []
|
|
14
|
+
@state = {}
|
|
15
|
+
|
|
16
|
+
# Replay events to rebuild state
|
|
17
|
+
events.each { |event| apply_event(event, from_history: true) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Command: Create feature
|
|
21
|
+
def create(rollout: 0, group: nil, metadata: {})
|
|
22
|
+
raise ArgumentError, 'Feature already exists' if created?
|
|
23
|
+
|
|
24
|
+
event = Events::FeatureCreated.new(
|
|
25
|
+
aggregate_id: @id,
|
|
26
|
+
data: {
|
|
27
|
+
rollout_percentage: parse_rollout(rollout),
|
|
28
|
+
group_matcher: group,
|
|
29
|
+
metadata: metadata
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
apply_event(event)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Command: Change state
|
|
37
|
+
def change_state(from_state, to_state, event_name, context = {})
|
|
38
|
+
unless can_transition?(from_state, to_state, event_name)
|
|
39
|
+
raise InvalidTransitionError, "Cannot transition from #{from_state} to #{to_state} via #{event_name}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
event = Events::FeatureStateChanged.new(
|
|
43
|
+
aggregate_id: @id,
|
|
44
|
+
from_state: from_state,
|
|
45
|
+
to_state: to_state,
|
|
46
|
+
event_name: event_name,
|
|
47
|
+
context: context
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
apply_event(event)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Command: Update configuration
|
|
54
|
+
def update_configuration(changes)
|
|
55
|
+
event = Events::FeatureConfigurationChanged.new(
|
|
56
|
+
aggregate_id: @id,
|
|
57
|
+
configuration_changes: changes
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
apply_event(event)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Command: Record execution
|
|
64
|
+
def record_execution(context, result, execution_time)
|
|
65
|
+
event = Events::FeatureExecuted.new(
|
|
66
|
+
aggregate_id: @id,
|
|
67
|
+
context: context,
|
|
68
|
+
result: result,
|
|
69
|
+
execution_time: execution_time
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
apply_event(event)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Command: Record evaluation
|
|
76
|
+
def record_evaluation(context, eligible, reason)
|
|
77
|
+
event = Events::FeatureEvaluated.new(
|
|
78
|
+
aggregate_id: @id,
|
|
79
|
+
context: context,
|
|
80
|
+
eligible: eligible,
|
|
81
|
+
reason: reason
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
apply_event(event)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Queries
|
|
88
|
+
def created?
|
|
89
|
+
@state[:created] || false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def current_state
|
|
93
|
+
@state[:current_state] || :disabled
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def rollout_percentage
|
|
97
|
+
@state[:rollout_percentage] || 0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def group_matcher
|
|
101
|
+
@state[:group_matcher]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def configuration
|
|
105
|
+
@state[:configuration] || {}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def transition_history
|
|
109
|
+
@state[:transitions] || []
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def execution_history
|
|
113
|
+
@state[:executions] || []
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def evaluation_history
|
|
117
|
+
@state[:evaluations] || []
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Mark events as committed
|
|
121
|
+
def mark_events_as_committed
|
|
122
|
+
@uncommitted_events.clear
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Get snapshot for rebuilding
|
|
126
|
+
def to_snapshot
|
|
127
|
+
{
|
|
128
|
+
id: @id,
|
|
129
|
+
version: @version,
|
|
130
|
+
state: @state.dup
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Load from snapshot
|
|
135
|
+
def self.from_snapshot(snapshot, events_since_snapshot = [])
|
|
136
|
+
aggregate = allocate
|
|
137
|
+
aggregate.instance_variable_set(:@id, snapshot[:id])
|
|
138
|
+
aggregate.instance_variable_set(:@version, snapshot[:version])
|
|
139
|
+
aggregate.instance_variable_set(:@state, snapshot[:state].dup)
|
|
140
|
+
aggregate.instance_variable_set(:@uncommitted_events, [])
|
|
141
|
+
|
|
142
|
+
# Apply events since snapshot
|
|
143
|
+
events_since_snapshot.each { |event| aggregate.apply_event(event, from_history: true) }
|
|
144
|
+
|
|
145
|
+
aggregate
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def apply_event(event, from_history: false)
|
|
151
|
+
case event
|
|
152
|
+
when Events::FeatureCreated
|
|
153
|
+
apply_feature_created(event)
|
|
154
|
+
when Events::FeatureStateChanged
|
|
155
|
+
apply_state_changed(event)
|
|
156
|
+
when Events::FeatureConfigurationChanged
|
|
157
|
+
apply_configuration_changed(event)
|
|
158
|
+
when Events::FeatureExecuted
|
|
159
|
+
apply_execution_recorded(event)
|
|
160
|
+
when Events::FeatureEvaluated
|
|
161
|
+
apply_evaluation_recorded(event)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
@version += 1 if from_history
|
|
165
|
+
@uncommitted_events << event unless from_history
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def apply_feature_created(event)
|
|
169
|
+
@state[:created] = true
|
|
170
|
+
@state[:current_state] = :disabled
|
|
171
|
+
@state[:rollout_percentage] = event.data[:rollout_percentage]
|
|
172
|
+
@state[:group_matcher] = event.data[:group_matcher]
|
|
173
|
+
@state[:metadata] = event.data[:metadata]
|
|
174
|
+
@state[:transitions] = []
|
|
175
|
+
@state[:executions] = []
|
|
176
|
+
@state[:evaluations] = []
|
|
177
|
+
@state[:configuration] = {}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def apply_state_changed(event)
|
|
181
|
+
@state[:current_state] = event.to_state
|
|
182
|
+
@state[:transitions] ||= []
|
|
183
|
+
@state[:transitions] << {
|
|
184
|
+
from_state: event.from_state,
|
|
185
|
+
to_state: event.to_state,
|
|
186
|
+
event_name: event.event_name,
|
|
187
|
+
context: event.context,
|
|
188
|
+
occurred_at: event.occurred_at
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Keep only last 50 transitions for memory efficiency
|
|
192
|
+
@state[:transitions] = @state[:transitions].last(50) if @state[:transitions].size > 50
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def apply_configuration_changed(event)
|
|
196
|
+
@state[:configuration] ||= {}
|
|
197
|
+
@state[:configuration].merge!(event.configuration_changes)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def apply_execution_recorded(event)
|
|
201
|
+
@state[:executions] ||= []
|
|
202
|
+
@state[:executions] << {
|
|
203
|
+
context: event.context,
|
|
204
|
+
result: event.result,
|
|
205
|
+
execution_time: event.execution_time,
|
|
206
|
+
occurred_at: event.occurred_at
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Keep only last 100 executions
|
|
210
|
+
@state[:executions] = @state[:executions].last(100) if @state[:executions].size > 100
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def apply_evaluation_recorded(event)
|
|
214
|
+
@state[:evaluations] ||= []
|
|
215
|
+
@state[:evaluations] << {
|
|
216
|
+
context: event.context,
|
|
217
|
+
eligible: event.eligible,
|
|
218
|
+
reason: event.reason,
|
|
219
|
+
occurred_at: event.occurred_at
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Keep only last 100 evaluations
|
|
223
|
+
@state[:evaluations] = @state[:evaluations].last(100) if @state[:evaluations].size > 100
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def can_transition?(from_state, to_state, event_name)
|
|
227
|
+
# Define valid transitions
|
|
228
|
+
valid_transitions = {
|
|
229
|
+
disabled: { rollout_canary: :canary, enable: :enabled },
|
|
230
|
+
canary: { enable: :enabled, disable: :disabled },
|
|
231
|
+
enabled: { disable: :disabled, rollout_canary: :canary }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
allowed_transitions = valid_transitions[from_state.to_sym] || {}
|
|
235
|
+
allowed_transitions[event_name.to_sym] == to_state.to_sym
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def parse_rollout(rollout)
|
|
239
|
+
case rollout
|
|
240
|
+
when Numeric, String
|
|
241
|
+
rollout.to_i
|
|
242
|
+
else
|
|
243
|
+
0
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
class InvalidTransitionError < StandardError; end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'feature_aggregate'
|
|
4
|
+
|
|
5
|
+
module StarGates
|
|
6
|
+
module Hexagonal
|
|
7
|
+
class FeatureRepository
|
|
8
|
+
def initialize(event_store)
|
|
9
|
+
@event_store = event_store
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Load feature aggregate from event store
|
|
13
|
+
def find(feature_id)
|
|
14
|
+
events = @event_store.get_events(feature_id)
|
|
15
|
+
return nil if events.empty?
|
|
16
|
+
|
|
17
|
+
FeatureAggregate.new(feature_id, events)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Save feature aggregate to event store
|
|
21
|
+
def save(aggregate, expected_version: nil)
|
|
22
|
+
return if aggregate.uncommitted_events.empty?
|
|
23
|
+
|
|
24
|
+
@event_store.append(
|
|
25
|
+
aggregate.id,
|
|
26
|
+
aggregate.uncommitted_events,
|
|
27
|
+
expected_version: expected_version
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
aggregate.mark_events_as_committed
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Find or create feature aggregate
|
|
34
|
+
def find_or_create(feature_id)
|
|
35
|
+
find(feature_id) || FeatureAggregate.new(feature_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if feature exists
|
|
39
|
+
def exists?(feature_id)
|
|
40
|
+
@event_store.get_version(feature_id).positive?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get all feature IDs that have events
|
|
44
|
+
def all_feature_ids
|
|
45
|
+
# This would need to be implemented by the event store adapter
|
|
46
|
+
# For now, we'll return an empty array as this requires scanning
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get features by state (requires read model/projection)
|
|
51
|
+
def find_by_state(state)
|
|
52
|
+
# This would typically be implemented using a read model/projection
|
|
53
|
+
# For now, we'll raise an error to indicate this needs a projection
|
|
54
|
+
raise NotImplementedError, 'Use FeatureProjection to query by state'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Health check
|
|
58
|
+
def healthy?
|
|
59
|
+
@event_store.respond_to?(:healthy?) ? @event_store.healthy? : true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
attr_reader :event_store
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'feature_repository'
|
|
4
|
+
require_relative '../projections/feature_projection'
|
|
5
|
+
|
|
6
|
+
module StarGates
|
|
7
|
+
module Hexagonal
|
|
8
|
+
class FeatureService
|
|
9
|
+
def initialize(repository:, projection:, event_store:)
|
|
10
|
+
@repository = repository
|
|
11
|
+
@projection = projection
|
|
12
|
+
@event_store = event_store
|
|
13
|
+
setup_event_subscriptions
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Commands
|
|
17
|
+
def create_feature(feature_id, rollout: 0, group: nil, metadata: {})
|
|
18
|
+
aggregate = @repository.find_or_create(feature_id)
|
|
19
|
+
|
|
20
|
+
unless aggregate.created?
|
|
21
|
+
aggregate.create(rollout: rollout, group: group, metadata: metadata)
|
|
22
|
+
@repository.save(aggregate)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
aggregate
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def change_feature_state(feature_id, from_state, to_state, event_name, context = {})
|
|
29
|
+
aggregate = @repository.find(feature_id)
|
|
30
|
+
raise FeatureNotFoundError, "Feature #{feature_id} not found" unless aggregate
|
|
31
|
+
|
|
32
|
+
aggregate.change_state(from_state, to_state, event_name, context)
|
|
33
|
+
@repository.save(aggregate)
|
|
34
|
+
|
|
35
|
+
aggregate
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def update_feature_configuration(feature_id, changes)
|
|
39
|
+
aggregate = @repository.find(feature_id)
|
|
40
|
+
raise FeatureNotFoundError, "Feature #{feature_id} not found" unless aggregate
|
|
41
|
+
|
|
42
|
+
aggregate.update_configuration(changes)
|
|
43
|
+
@repository.save(aggregate)
|
|
44
|
+
|
|
45
|
+
aggregate
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def record_feature_execution(feature_id, context, result, execution_time)
|
|
49
|
+
aggregate = @repository.find(feature_id)
|
|
50
|
+
return unless aggregate # Silently skip if feature doesn't exist
|
|
51
|
+
|
|
52
|
+
aggregate.record_execution(context, result, execution_time)
|
|
53
|
+
@repository.save(aggregate)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def record_feature_evaluation(feature_id, context, eligible, reason)
|
|
57
|
+
aggregate = @repository.find(feature_id)
|
|
58
|
+
return unless aggregate # Silently skip if feature doesn't exist
|
|
59
|
+
|
|
60
|
+
aggregate.record_evaluation(context, eligible, reason)
|
|
61
|
+
@repository.save(aggregate)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Queries (delegated to projection)
|
|
65
|
+
def find_feature(feature_id)
|
|
66
|
+
@projection.find_feature(feature_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def find_features_by_state(state)
|
|
70
|
+
@projection.find_features_by_state(state)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def all_features
|
|
74
|
+
@projection.all_features
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def feature_stats
|
|
78
|
+
@projection.feature_stats
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def recent_transitions(limit: 10)
|
|
82
|
+
@projection.recent_transitions(limit: limit)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def feature_exists?(feature_id)
|
|
86
|
+
@projection.feature_exists?(feature_id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get feature aggregate for complex operations
|
|
90
|
+
def get_feature_aggregate(feature_id)
|
|
91
|
+
@repository.find(feature_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Replay events to rebuild projections
|
|
95
|
+
def rebuild_projections!
|
|
96
|
+
@projection.clear! if @projection.respond_to?(:clear!)
|
|
97
|
+
|
|
98
|
+
@event_store.replay_all do |event|
|
|
99
|
+
@projection.handle_event(event)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Health check
|
|
104
|
+
def healthy?
|
|
105
|
+
@repository.healthy? &&
|
|
106
|
+
(@event_store.respond_to?(:healthy?) ? @event_store.healthy? : true)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def setup_event_subscriptions
|
|
112
|
+
# Subscribe to all events to update projections
|
|
113
|
+
@event_store.subscribe do |event|
|
|
114
|
+
@projection.handle_event(event)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class FeatureNotFoundError < StandardError; end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StarGates
|
|
4
|
+
# Optional JSON-RPC adapter using jsonrpc-rails gem
|
|
5
|
+
# Include this in your Rails controllers when jsonrpc-rails is available
|
|
6
|
+
module JsonrpcRailsAdapter
|
|
7
|
+
def self.available?
|
|
8
|
+
defined?(JSON_RPC) && defined?(ActionController::Base)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.included(_base)
|
|
12
|
+
return if available?
|
|
13
|
+
|
|
14
|
+
raise LoadError, "jsonrpc-rails gem is required. Add 'gem \"jsonrpc-rails\"' to your Gemfile"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Main JSON-RPC request handler
|
|
18
|
+
def process
|
|
19
|
+
if jsonrpc_params_batch?
|
|
20
|
+
# Handle batch requests
|
|
21
|
+
responses = jsonrpc_params.filter_map { |req| handle_single_request(req) }
|
|
22
|
+
|
|
23
|
+
if responses.empty?
|
|
24
|
+
head :no_content
|
|
25
|
+
else
|
|
26
|
+
render json: responses.map(&:to_h), status: :ok
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
# Handle single request
|
|
30
|
+
response = handle_single_request(jsonrpc_params)
|
|
31
|
+
|
|
32
|
+
if response # request (has id)
|
|
33
|
+
render jsonrpc: response.result,
|
|
34
|
+
id: response.id,
|
|
35
|
+
error: response.error
|
|
36
|
+
else # notification
|
|
37
|
+
head :no_content
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Map one JSON_RPC::Request|Notification → Response|nil
|
|
45
|
+
def handle_single_request(req)
|
|
46
|
+
# Notifications have id == nil. Return nil = no response.
|
|
47
|
+
return nil if req.id.nil?
|
|
48
|
+
|
|
49
|
+
result_or_error = dispatch_method(req.method, req.params || {})
|
|
50
|
+
build_response(req.id, result_or_error)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Dispatch JSON-RPC method to appropriate handler
|
|
54
|
+
def dispatch_method(method, params)
|
|
55
|
+
case method
|
|
56
|
+
when 'feature.create'
|
|
57
|
+
create_feature(params)
|
|
58
|
+
when 'feature.get'
|
|
59
|
+
get_feature(params)
|
|
60
|
+
when 'feature.list'
|
|
61
|
+
list_features(params)
|
|
62
|
+
when 'feature.changeState'
|
|
63
|
+
change_state(params)
|
|
64
|
+
when 'feature.execute'
|
|
65
|
+
execute_feature(params)
|
|
66
|
+
when 'feature.evaluate'
|
|
67
|
+
evaluate_feature(params)
|
|
68
|
+
when 'feature.stats'
|
|
69
|
+
feature_stats(params)
|
|
70
|
+
when 'feature.recentTransitions'
|
|
71
|
+
recent_transitions(params)
|
|
72
|
+
when 'system.health'
|
|
73
|
+
system_health(params)
|
|
74
|
+
when 'system.rebuildProjections'
|
|
75
|
+
rebuild_projections(params)
|
|
76
|
+
else
|
|
77
|
+
JSON_RPC::JsonRpcError.build(:method_not_found)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Feature management methods
|
|
82
|
+
def create_feature(params)
|
|
83
|
+
validate_required_params(params, ['featureId'])
|
|
84
|
+
|
|
85
|
+
feature_id = params['featureId']
|
|
86
|
+
rollout = params['rollout'] || 0
|
|
87
|
+
group = params['group']
|
|
88
|
+
metadata = params['metadata'] || {}
|
|
89
|
+
|
|
90
|
+
aggregate = feature_service.create_feature(feature_id, rollout: rollout, group: group, metadata: metadata)
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
featureId: aggregate.id,
|
|
94
|
+
created: aggregate.created?,
|
|
95
|
+
state: aggregate.current_state,
|
|
96
|
+
rolloutPercentage: aggregate.rollout_percentage,
|
|
97
|
+
groupMatcher: aggregate.group_matcher
|
|
98
|
+
}
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
JSON_RPC::JsonRpcError.build(:invalid_params, data: { error: e.message })
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def get_feature(params)
|
|
104
|
+
validate_required_params(params, ['featureId'])
|
|
105
|
+
|
|
106
|
+
feature = feature_service.find_feature(params['featureId'])
|
|
107
|
+
return JSON_RPC::JsonRpcError.build(:invalid_params, data: { error: 'Feature not found' }) unless feature
|
|
108
|
+
|
|
109
|
+
feature
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def list_features(params)
|
|
115
|
+
state = params['state']
|
|
116
|
+
|
|
117
|
+
features = if state
|
|
118
|
+
feature_service.find_features_by_state(state.to_sym)
|
|
119
|
+
else
|
|
120
|
+
feature_service.all_features
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
{ features: features }
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def change_state(params)
|
|
129
|
+
validate_required_params(params, %w[featureId fromState toState event])
|
|
130
|
+
|
|
131
|
+
feature_id = params['featureId']
|
|
132
|
+
from_state = params['fromState'].to_sym
|
|
133
|
+
to_state = params['toState'].to_sym
|
|
134
|
+
event_name = params['event'].to_sym
|
|
135
|
+
context = params['context'] || {}
|
|
136
|
+
|
|
137
|
+
aggregate = feature_service.change_feature_state(feature_id, from_state, to_state, event_name, context)
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
featureId: aggregate.id,
|
|
141
|
+
fromState: from_state,
|
|
142
|
+
toState: to_state,
|
|
143
|
+
event: event_name,
|
|
144
|
+
success: true
|
|
145
|
+
}
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
JSON_RPC::JsonRpcError.build(:invalid_params, data: { error: "State transition failed: #{e.message}" })
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def execute_feature(params)
|
|
151
|
+
validate_required_params(params, ['featureId'])
|
|
152
|
+
|
|
153
|
+
feature_id = params['featureId']
|
|
154
|
+
context = params['context'] || {}
|
|
155
|
+
|
|
156
|
+
start_time = Time.now
|
|
157
|
+
result = yield if block_given? # Allow caller to provide execution block
|
|
158
|
+
execution_time = ((Time.now - start_time) * 1000).round(2) # ms
|
|
159
|
+
|
|
160
|
+
feature_service.record_feature_execution(feature_id, context, result, execution_time)
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
featureId: feature_id,
|
|
164
|
+
result: result,
|
|
165
|
+
executionTime: execution_time,
|
|
166
|
+
context: context
|
|
167
|
+
}
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def evaluate_feature(params)
|
|
173
|
+
validate_required_params(params, %w[featureId context])
|
|
174
|
+
|
|
175
|
+
feature_id = params['featureId']
|
|
176
|
+
context = params['context']
|
|
177
|
+
eligible = params['eligible'] || false
|
|
178
|
+
reason = params['reason'] || 'No reason provided'
|
|
179
|
+
|
|
180
|
+
feature_service.record_feature_evaluation(feature_id, context, eligible, reason)
|
|
181
|
+
|
|
182
|
+
{
|
|
183
|
+
featureId: feature_id,
|
|
184
|
+
context: context,
|
|
185
|
+
eligible: eligible,
|
|
186
|
+
reason: reason,
|
|
187
|
+
recordedAt: Time.now.utc.iso8601
|
|
188
|
+
}
|
|
189
|
+
rescue StandardError => e
|
|
190
|
+
JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def feature_stats(_params)
|
|
194
|
+
feature_service.feature_stats
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def recent_transitions(params)
|
|
200
|
+
limit = params['limit'] || 10
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
transitions: feature_service.recent_transitions(limit: limit),
|
|
204
|
+
limit: limit
|
|
205
|
+
}
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def system_health(_params)
|
|
211
|
+
{
|
|
212
|
+
healthy: feature_service.healthy?,
|
|
213
|
+
timestamp: Time.now.utc.iso8601,
|
|
214
|
+
version: StarGates::VERSION
|
|
215
|
+
}
|
|
216
|
+
rescue StandardError => e
|
|
217
|
+
JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def rebuild_projections(_params)
|
|
221
|
+
feature_service.rebuild_projections!
|
|
222
|
+
|
|
223
|
+
{
|
|
224
|
+
success: true,
|
|
225
|
+
rebuiltAt: Time.now.utc.iso8601
|
|
226
|
+
}
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
JSON_RPC::JsonRpcError.build(:internal_error, data: { error: e.message })
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Helper methods
|
|
232
|
+
def validate_required_params(params, required_keys)
|
|
233
|
+
missing_keys = required_keys - params.keys
|
|
234
|
+
return if missing_keys.empty?
|
|
235
|
+
|
|
236
|
+
raise ArgumentError, "Missing required parameters: #{missing_keys.join(', ')}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def build_response(id, outcome)
|
|
240
|
+
if outcome.is_a?(JSON_RPC::JsonRpcError)
|
|
241
|
+
JSON_RPC::Response.new(id: id, error: outcome)
|
|
242
|
+
else
|
|
243
|
+
JSON_RPC::Response.new(id: id, result: outcome)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def feature_service
|
|
248
|
+
@feature_service ||= StarGates.feature_service
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|