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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.dictate.toml +19 -0
  3. data/.pre-commit-config.yaml +20 -0
  4. data/CHANGELOG.md +60 -3
  5. data/LICENSE.txt +23 -16
  6. data/README.md +182 -16
  7. data/Rakefile +14 -4
  8. data/lib/star_gates/adapters/base_adapter.rb +65 -0
  9. data/lib/star_gates/adapters/memory_adapter.rb +93 -0
  10. data/lib/star_gates/adapters/redis_adapter.rb +119 -0
  11. data/lib/star_gates/application_star_gate.rb +398 -0
  12. data/lib/star_gates/circuit.rb +171 -0
  13. data/lib/star_gates/configuration.rb +172 -0
  14. data/lib/star_gates/dsl.rb +71 -2
  15. data/lib/star_gates/errors.rb +8 -0
  16. data/lib/star_gates/event_store/event_store.rb +79 -0
  17. data/lib/star_gates/event_store/memory_adapter.rb +78 -0
  18. data/lib/star_gates/events/base_event.rb +45 -0
  19. data/lib/star_gates/events/feature_events.rb +97 -0
  20. data/lib/star_gates/feature.rb +426 -0
  21. data/lib/star_gates/flags.rb +153 -0
  22. data/lib/star_gates/gate.rb +100 -0
  23. data/lib/star_gates/hexagonal/feature_aggregate.rb +250 -0
  24. data/lib/star_gates/hexagonal/feature_repository.rb +67 -0
  25. data/lib/star_gates/hexagonal/feature_service.rb +121 -0
  26. data/lib/star_gates/jsonrpc_rails_adapter.rb +251 -0
  27. data/lib/star_gates/projections/feature_projection.rb +224 -0
  28. data/lib/star_gates/registry.rb +58 -0
  29. data/lib/star_gates/telemetry/opentelemetry_adapter.rb +252 -0
  30. data/lib/star_gates/telemetry/prometheus_adapter.rb +183 -0
  31. data/lib/star_gates/telemetry/telemetry_adapter.rb +160 -0
  32. data/lib/star_gates/version.rb +1 -1
  33. data/lib/star_gates.rb +308 -8
  34. metadata +95 -14
  35. data/sig/star_gates.rbs +0 -4
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StarGates
4
+ class Configuration
5
+ attr_accessor :instrumentation, :telemetry_adapter, :default_timeout, :default_retry_count,
6
+ :circuit_breaker_threshold, :cache_ttl, :environment, :feature_defaults,
7
+ :enable_performance_monitoring, :log_level, :error_reporting
8
+
9
+ def initialize
10
+ @instrumentation = true
11
+ @telemetry_adapter = detect_default_telemetry_adapter
12
+ @default_timeout = 5.0 # seconds
13
+ @default_retry_count = 3
14
+ @circuit_breaker_threshold = 5
15
+ @cache_ttl = 300 # 5 minutes
16
+ @environment = detect_environment
17
+ @feature_defaults = {}
18
+ @enable_performance_monitoring = true
19
+ @log_level = :info
20
+ @error_reporting = true
21
+ end
22
+
23
+ # Environment-specific configurations
24
+ def development?
25
+ environment == :development
26
+ end
27
+
28
+ def test?
29
+ environment == :test
30
+ end
31
+
32
+ def production?
33
+ environment == :production
34
+ end
35
+
36
+ def staging?
37
+ environment == :staging
38
+ end
39
+
40
+ # Feature-specific defaults
41
+ def feature_config_for(feature_name)
42
+ base_config = {
43
+ timeout: default_timeout,
44
+ retry_count: default_retry_count,
45
+ circuit_breaker_threshold: circuit_breaker_threshold,
46
+ cache_ttl: cache_ttl,
47
+ instrumentation: instrumentation,
48
+ performance_monitoring: enable_performance_monitoring
49
+ }
50
+
51
+ # Merge with feature-specific overrides
52
+ feature_specific = feature_defaults[feature_name.to_sym] || {}
53
+ base_config.merge(feature_specific)
54
+ end
55
+
56
+ # Configure feature-specific defaults
57
+ def configure_feature(feature_name, config = {})
58
+ feature_defaults[feature_name.to_sym] = config
59
+ end
60
+
61
+ # Load from YAML file (Rails-style)
62
+ def load_from_file(file_path)
63
+ return unless File.exist?(file_path)
64
+
65
+ require 'yaml'
66
+ # Use safe_load_file to prevent arbitrary code execution
67
+ config_data = YAML.safe_load_file(
68
+ file_path,
69
+ permitted_classes: [Symbol, Time, Date],
70
+ aliases: true
71
+ )
72
+ env_config = config_data[environment.to_s] || {}
73
+
74
+ env_config.each do |key, value|
75
+ setter_method = "#{key}="
76
+ if respond_to?(setter_method)
77
+ send(setter_method, value)
78
+ elsif key == 'features'
79
+ # Handle feature-specific configurations
80
+ value.each { |feature_name, feature_config| configure_feature(feature_name, feature_config) }
81
+ end
82
+ end
83
+ end
84
+
85
+ # Environment-aware defaults
86
+ def apply_environment_defaults!
87
+ case environment
88
+ when :development
89
+ @log_level = :debug
90
+ @default_timeout = 10.0 # More generous timeout for dev
91
+ @telemetry_adapter = :memory
92
+ when :test
93
+ @instrumentation = false
94
+ @telemetry_adapter = :memory
95
+ @default_timeout = 1.0 # Fast tests
96
+ @enable_performance_monitoring = false
97
+ when :production
98
+ @log_level = :warn
99
+ @default_timeout = 2.0 # Strict timeout for prod
100
+ @telemetry_adapter = :prometheus
101
+ @error_reporting = true
102
+ when :staging
103
+ @log_level = :info
104
+ @default_timeout = 5.0
105
+ @telemetry_adapter = :prometheus
106
+ end
107
+ end
108
+
109
+ # Convert to hash for serialization
110
+ def to_h
111
+ {
112
+ instrumentation: instrumentation,
113
+ telemetry_adapter: telemetry_adapter,
114
+ default_timeout: default_timeout,
115
+ default_retry_count: default_retry_count,
116
+ circuit_breaker_threshold: circuit_breaker_threshold,
117
+ cache_ttl: cache_ttl,
118
+ environment: environment,
119
+ enable_performance_monitoring: enable_performance_monitoring,
120
+ log_level: log_level,
121
+ error_reporting: error_reporting,
122
+ feature_defaults: feature_defaults
123
+ }
124
+ end
125
+
126
+ private
127
+
128
+ def detect_environment
129
+ if defined?(Rails) && Rails.respond_to?(:env)
130
+ Rails.env.to_sym
131
+ elsif ENV['RACK_ENV']
132
+ ENV['RACK_ENV'].to_sym
133
+ elsif ENV['RAILS_ENV']
134
+ ENV['RAILS_ENV'].to_sym
135
+ elsif ENV['RUBY_ENV']
136
+ ENV['RUBY_ENV'].to_sym
137
+ else
138
+ :development
139
+ end
140
+ end
141
+
142
+ def detect_default_telemetry_adapter
143
+ return :prometheus if defined?(Prometheus)
144
+ return :opentelemetry if defined?(OpenTelemetry)
145
+
146
+ :memory
147
+ end
148
+ end
149
+
150
+ # Global configuration instance
151
+ def self.configuration
152
+ @configuration ||= Configuration.new.tap(&:apply_environment_defaults!)
153
+ end
154
+
155
+ def self.configure
156
+ yield(configuration) if block_given?
157
+ end
158
+
159
+ # Load configuration from file
160
+ def self.load_config!(file_path = nil)
161
+ file_path ||= default_config_file_path
162
+ configuration.load_from_file(file_path) if file_path
163
+ end
164
+
165
+ def self.default_config_file_path
166
+ if defined?(Rails)
167
+ Rails.root.join('config', 'star_gates.yml')
168
+ else
169
+ File.join(Dir.pwd, 'config', 'star_gates.yml')
170
+ end
171
+ end
172
+ end
@@ -2,6 +2,75 @@
2
2
 
3
3
  module StarGates
4
4
  module DSL
5
- # DSL methods will be implemented here
5
+ class GateBuilder
6
+ attr_reader :name, :feature_config, :circuit_config, :run_block, :fallback_block
7
+
8
+ def initialize(name)
9
+ @name = name
10
+ @feature_config = {}
11
+ @circuit_config = {}
12
+ @run_block = nil
13
+ @fallback_block = nil
14
+ end
15
+
16
+ def feature(rollout: 0, group: nil)
17
+ @feature_config = { rollout: rollout, group: group }
18
+ end
19
+
20
+ def circuit(threshold: 5, within: 30.seconds, reset_after: 1.minute, retry_config: nil)
21
+ @circuit_config = {
22
+ threshold: threshold,
23
+ within: within,
24
+ reset_after: reset_after,
25
+ retry_config: retry_config
26
+ }
27
+ end
28
+
29
+ def run(&block)
30
+ @run_block = block
31
+ end
32
+
33
+ def fallback(&block)
34
+ @fallback_block = block
35
+ end
36
+
37
+ def build
38
+ feature_instance = build_feature if feature_config.any?
39
+ circuit_instance = build_circuit(feature_instance) if circuit_config.any?
40
+
41
+ StarGates::Gate.new(
42
+ name,
43
+ feature: feature_instance,
44
+ circuit: circuit_instance,
45
+ run_block: run_block,
46
+ fallback_block: fallback_block
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def build_feature
53
+ StarGates::Feature.new(**feature_config)
54
+ end
55
+
56
+ def build_circuit(feature)
57
+ # Wire circuit to feature for self-healing behavior
58
+ StarGates::Circuit.new(**circuit_config, feature: feature)
59
+ end
60
+ end
61
+
62
+ class DefineContext
63
+ def initialize(registry)
64
+ @registry = registry
65
+ end
66
+
67
+ def gate(name, &)
68
+ builder = GateBuilder.new(name)
69
+ builder.instance_eval(&) if block_given?
70
+ gate_instance = builder.build
71
+ @registry.register(name, gate_instance)
72
+ gate_instance
73
+ end
74
+ end
6
75
  end
7
- end
76
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StarGates
4
+ class Error < StandardError; end
5
+ class UnavailableError < Error; end
6
+ class GateNotFoundError < Error; end
7
+ class InvalidConfigurationError < Error; end
8
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StarGates
4
+ module EventStore
5
+ class EventStore
6
+ def initialize(adapter: nil)
7
+ @adapter = adapter || MemoryAdapter.new
8
+ @subscribers = Hash.new { |h, k| h[k] = [] }
9
+ end
10
+
11
+ # Append events to the store
12
+ def append(aggregate_id, events, expected_version: nil)
13
+ events = Array(events)
14
+ return if events.empty?
15
+
16
+ # Validate expected version for optimistic concurrency
17
+ if expected_version
18
+ current_version = @adapter.get_version(aggregate_id)
19
+ if current_version != expected_version
20
+ raise ConcurrencyError, "Expected version #{expected_version}, got #{current_version}"
21
+ end
22
+ end
23
+
24
+ # Store events
25
+ @adapter.append(aggregate_id, events)
26
+
27
+ # Notify subscribers
28
+ events.each do |event|
29
+ notify_subscribers(event)
30
+ end
31
+
32
+ events.size
33
+ end
34
+
35
+ # Get events for an aggregate
36
+ def get_events(aggregate_id, from_version: 0)
37
+ @adapter.get_events(aggregate_id, from_version: from_version)
38
+ end
39
+
40
+ # Get all events from a specific time
41
+ def get_events_from(timestamp, limit: 100)
42
+ @adapter.get_events_from(timestamp, limit: limit)
43
+ end
44
+
45
+ # Get events by type
46
+ def get_events_by_type(event_type, limit: 100)
47
+ @adapter.get_events_by_type(event_type, limit: limit)
48
+ end
49
+
50
+ # Subscribe to events
51
+ def subscribe(event_type = nil, &block)
52
+ key = event_type || :all
53
+ @subscribers[key] << block
54
+ end
55
+
56
+ # Get current version of aggregate
57
+ def get_version(aggregate_id)
58
+ @adapter.get_version(aggregate_id)
59
+ end
60
+
61
+ # Replay all events to rebuild projections
62
+ def replay_all(&)
63
+ @adapter.replay_all(&)
64
+ end
65
+
66
+ private
67
+
68
+ def notify_subscribers(event)
69
+ # Notify specific event type subscribers
70
+ @subscribers[event.event_type].each { |subscriber| subscriber.call(event) }
71
+
72
+ # Notify all event subscribers
73
+ @subscribers[:all].each { |subscriber| subscriber.call(event) }
74
+ end
75
+ end
76
+
77
+ class ConcurrencyError < StandardError; end
78
+ end
79
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module StarGates
6
+ module EventStore
7
+ class MemoryAdapter
8
+ def initialize
9
+ @events = Concurrent::Hash.new { |h, k| h[k] = Concurrent::Array.new }
10
+ @global_events = Concurrent::Array.new
11
+ @versions = Concurrent::Hash.new(0)
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def append(aggregate_id, events)
16
+ @mutex.synchronize do
17
+ events.each do |event|
18
+ # Increment version
19
+ @versions[aggregate_id] += 1
20
+ versioned_event = event.dup.tap do |e|
21
+ e.instance_variable_set(:@version, @versions[aggregate_id])
22
+ end
23
+
24
+ # Store by aggregate
25
+ @events[aggregate_id] << versioned_event
26
+
27
+ # Store globally for cross-aggregate queries
28
+ @global_events << versioned_event
29
+ end
30
+ end
31
+ end
32
+
33
+ def get_events(aggregate_id, from_version: 0)
34
+ @events[aggregate_id].select { |event| event.version > from_version }
35
+ end
36
+
37
+ def get_events_from(timestamp, limit: 100)
38
+ @global_events
39
+ .select { |event| event.occurred_at >= timestamp }
40
+ .sort_by(&:occurred_at)
41
+ .reverse
42
+ .first(limit)
43
+ end
44
+
45
+ def get_events_by_type(event_type, limit: 100)
46
+ @global_events
47
+ .select { |event| event.event_type == event_type }
48
+ .sort_by(&:occurred_at)
49
+ .reverse
50
+ .first(limit)
51
+ end
52
+
53
+ def get_version(aggregate_id)
54
+ @versions[aggregate_id]
55
+ end
56
+
57
+ def replay_all(&)
58
+ @global_events.sort_by(&:occurred_at).each(&)
59
+ end
60
+
61
+ def clear!
62
+ @mutex.synchronize do
63
+ @events.clear
64
+ @global_events.clear
65
+ @versions.clear
66
+ end
67
+ end
68
+
69
+ def size
70
+ @global_events.size
71
+ end
72
+
73
+ def aggregate_count
74
+ @events.size
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module StarGates
6
+ module Events
7
+ class BaseEvent
8
+ attr_reader :event_id, :aggregate_id, :event_type, :data, :metadata, :occurred_at, :version
9
+
10
+ def initialize(aggregate_id:, data: {}, metadata: {}, version: 1)
11
+ @event_id = SecureRandom.uuid
12
+ @aggregate_id = aggregate_id
13
+ @event_type = self.class.name.split('::').last
14
+ @data = data.freeze
15
+ @metadata = metadata.merge(
16
+ source: 'star_gates',
17
+ correlation_id: Thread.current[:correlation_id] || SecureRandom.uuid
18
+ ).freeze
19
+ @occurred_at = Time.now.utc
20
+ @version = version
21
+ freeze
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ event_id: event_id,
27
+ aggregate_id: aggregate_id,
28
+ event_type: event_type,
29
+ data: data,
30
+ metadata: metadata,
31
+ occurred_at: occurred_at,
32
+ version: version
33
+ }
34
+ end
35
+
36
+ def ==(other)
37
+ other.is_a?(self.class) && event_id == other.event_id
38
+ end
39
+
40
+ def hash
41
+ event_id.hash
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_event'
4
+
5
+ module StarGates
6
+ module Events
7
+ # Feature lifecycle events
8
+ class FeatureCreated < BaseEvent; end
9
+
10
+ class FeatureStateChanged < BaseEvent
11
+ def initialize(aggregate_id:, from_state:, to_state:, event_name:, context: {}, **opts)
12
+ super(
13
+ aggregate_id: aggregate_id,
14
+ data: {
15
+ from_state: from_state,
16
+ to_state: to_state,
17
+ event_name: event_name,
18
+ context: context
19
+ },
20
+ **opts
21
+ )
22
+ end
23
+
24
+ def from_state = data[:from_state]
25
+ def to_state = data[:to_state]
26
+ def event_name = data[:event_name]
27
+ def context = data[:context]
28
+ end
29
+
30
+ class FeatureConfigurationChanged < BaseEvent
31
+ def initialize(aggregate_id:, configuration_changes:, **opts)
32
+ super(
33
+ aggregate_id: aggregate_id,
34
+ data: { configuration_changes: configuration_changes },
35
+ **opts
36
+ )
37
+ end
38
+
39
+ def configuration_changes = data[:configuration_changes]
40
+ end
41
+
42
+ class FeatureExecuted < BaseEvent
43
+ def initialize(aggregate_id:, context:, result:, execution_time:, **opts)
44
+ super(
45
+ aggregate_id: aggregate_id,
46
+ data: {
47
+ context: context,
48
+ result: result,
49
+ execution_time: execution_time
50
+ },
51
+ **opts
52
+ )
53
+ end
54
+
55
+ def context = data[:context]
56
+ def result = data[:result]
57
+ def execution_time = data[:execution_time]
58
+ end
59
+
60
+ class FeatureEvaluated < BaseEvent
61
+ def initialize(aggregate_id:, context:, eligible:, reason:, **opts)
62
+ super(
63
+ aggregate_id: aggregate_id,
64
+ data: {
65
+ context: context,
66
+ eligible: eligible,
67
+ reason: reason
68
+ },
69
+ **opts
70
+ )
71
+ end
72
+
73
+ def context = data[:context]
74
+ def eligible = data[:eligible]
75
+ def reason = data[:reason]
76
+ end
77
+
78
+ # System-level events
79
+ class GateRegistered < BaseEvent
80
+ def initialize(aggregate_id:, gate_name:, feature_class:, **opts)
81
+ super(
82
+ aggregate_id: aggregate_id,
83
+ data: {
84
+ gate_name: gate_name,
85
+ feature_class: feature_class
86
+ },
87
+ **opts
88
+ )
89
+ end
90
+
91
+ def gate_name = data[:gate_name]
92
+ def feature_class = data[:feature_class]
93
+ end
94
+
95
+ class SystemReset < BaseEvent; end
96
+ end
97
+ end