togglecraft 1.0.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.
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module ToggleCraft
6
+ # Flag evaluation engine for ToggleCraft Ruby SDK
7
+ # Evaluates boolean, multivariate, and percentage flags based on rules and context
8
+ class Evaluator
9
+ def initialize
10
+ @flags = Concurrent::Map.new
11
+ end
12
+
13
+ # Update the flags data
14
+ # @param flags [Hash] The flags payload
15
+ def update_flags(flags)
16
+ @flags.clear
17
+ flags&.each { |key, value| @flags[key] = value }
18
+ end
19
+
20
+ # Evaluate a boolean flag
21
+ # @param flag_key [String] The flag key
22
+ # @param context [Hash] The evaluation context
23
+ # @param default_value [Boolean] Default value if flag not found
24
+ # @return [Boolean]
25
+ def evaluate_boolean(flag_key, context = {}, default_value = false)
26
+ flag = @flags[flag_key]
27
+
28
+ # Flag doesn't exist
29
+ return default_value unless flag
30
+
31
+ # Flag is not a boolean type
32
+ unless flag[:type] == 'boolean'
33
+ warn "[ToggleCraft] Flag #{flag_key} is not a boolean flag"
34
+ return default_value
35
+ end
36
+
37
+ # Flag is disabled
38
+ return false unless flag[:enabled]
39
+
40
+ # Evaluate targeting rules
41
+ if flag[:rules]&.any?
42
+ flag[:rules].each do |rule|
43
+ if evaluate_rule(rule, context)
44
+ return rule[:value].nil? ? flag[:value] : rule[:value]
45
+ end
46
+ end
47
+ end
48
+
49
+ # Return default flag value
50
+ flag[:value].nil? ? default_value : flag[:value]
51
+ end
52
+
53
+ # Evaluate a multivariate flag
54
+ # @param flag_key [String] The flag key
55
+ # @param context [Hash] The evaluation context
56
+ # @param default_variant [String, nil] Default variant if flag not found
57
+ # @return [String, nil]
58
+ def evaluate_multivariate(flag_key, context = {}, default_variant = nil)
59
+ flag = @flags[flag_key]
60
+
61
+ # Flag doesn't exist
62
+ return default_variant unless flag
63
+
64
+ # Flag is not a multivariate type
65
+ unless flag[:type] == 'multivariate'
66
+ warn "[ToggleCraft] Flag #{flag_key} is not a multivariate flag"
67
+ return default_variant
68
+ end
69
+
70
+ # Flag is disabled
71
+ return default_variant unless flag[:enabled]
72
+
73
+ # Evaluate targeting rules
74
+ if flag[:rules]&.any?
75
+ flag[:rules].each do |rule|
76
+ return rule[:variant] || flag[:default_variant] || default_variant if evaluate_rule(rule, context)
77
+ end
78
+ end
79
+
80
+ # Use weighted distribution if weights are defined
81
+ if flag[:weights]&.any?
82
+ variant = select_variant_by_weight(flag_key, flag[:weights], context)
83
+ return variant if variant
84
+ end
85
+
86
+ # Return default variant
87
+ flag[:default_variant] || default_variant
88
+ end
89
+
90
+ # Evaluate a percentage rollout flag
91
+ # @param flag_key [String] The flag key
92
+ # @param context [Hash] The evaluation context
93
+ # @param default_value [Boolean] Default value if flag not found
94
+ # @return [Boolean]
95
+ def evaluate_percentage(flag_key, context = {}, default_value = false)
96
+ flag = @flags[flag_key]
97
+
98
+ # Flag doesn't exist
99
+ return default_value unless flag
100
+
101
+ # Flag is not a percentage type
102
+ unless flag[:type] == 'percentage'
103
+ warn "[ToggleCraft] Flag #{flag_key} is not a percentage flag"
104
+ return default_value
105
+ end
106
+
107
+ # Flag is disabled
108
+ return false unless flag[:enabled]
109
+
110
+ # Evaluate targeting rules first
111
+ if flag[:rules]&.any?
112
+ flag[:rules].each do |rule|
113
+ return rule[:enabled].nil? || rule[:enabled] if evaluate_rule(rule, context)
114
+ end
115
+ end
116
+
117
+ # Get current percentage (considering rollout stages)
118
+ percentage = current_percentage_for_flag(flag)
119
+
120
+ # Check percentage boundaries
121
+ return false if percentage <= 0
122
+ return true if percentage >= 100
123
+
124
+ # Use consistent hashing based on user ID or random value
125
+ user_id = context.dig(:user, :id) || context.dig(:user, :email) || SecureRandom.uuid
126
+ hash_value = Utils.hash_key("#{flag_key}:#{user_id}")
127
+
128
+ hash_value < percentage
129
+ end
130
+
131
+ # Calculate the current percentage for a flag based on rollout stages
132
+ # @param flag [Hash] The percentage flag object
133
+ # @return [Integer] The current percentage to use
134
+ def current_percentage_for_flag(flag)
135
+ # If no rollout stages, use base percentage
136
+ return flag[:percentage] if flag[:rollout_stages].nil? || flag[:rollout_stages].empty?
137
+
138
+ now = Time.now
139
+ active_stage = nil
140
+ latest_start_time = nil
141
+
142
+ # Find the most recent active stage
143
+ flag[:rollout_stages].each do |stage|
144
+ start_at = Time.parse(stage[:start_at])
145
+ end_at = stage[:end_at] ? Time.parse(stage[:end_at]) : nil
146
+
147
+ # Check if stage is currently active
148
+ is_active = start_at <= now && (end_at.nil? || end_at > now)
149
+
150
+ if is_active && (latest_start_time.nil? || start_at > latest_start_time)
151
+ active_stage = stage
152
+ latest_start_time = start_at
153
+ end
154
+ end
155
+
156
+ # Return active stage percentage, or fall back to base percentage
157
+ active_stage ? active_stage[:percentage] : flag[:percentage]
158
+ end
159
+
160
+ # Get the current percentage value of a flag
161
+ # @param flag_key [String] The flag key
162
+ # @return [Integer, nil]
163
+ def percentage(flag_key)
164
+ flag = @flags[flag_key]
165
+
166
+ return nil unless flag && flag[:type] == 'percentage'
167
+
168
+ current_percentage_for_flag(flag)
169
+ end
170
+
171
+ # Evaluate a single rule
172
+ # @param rule [Hash] The rule to evaluate
173
+ # @param context [Hash] The evaluation context
174
+ # @return [Boolean]
175
+ def evaluate_rule(rule, context)
176
+ return false if rule[:conditions].nil? || rule[:conditions].empty?
177
+
178
+ result = nil
179
+ previous_combinator = nil
180
+
181
+ rule[:conditions].each do |condition|
182
+ condition_result = evaluate_condition(condition, context)
183
+
184
+ result = if result.nil?
185
+ condition_result
186
+ else
187
+ # Apply combinator logic
188
+ case previous_combinator
189
+ when 'OR'
190
+ result || condition_result
191
+ else
192
+ # Default to AND
193
+ result && condition_result
194
+ end
195
+ end
196
+
197
+ previous_combinator = condition[:combinator] || 'AND'
198
+
199
+ # Early exit for AND conditions
200
+ return false if !result && previous_combinator == 'AND'
201
+
202
+ # Early exit for OR conditions
203
+ return true if result && previous_combinator == 'OR'
204
+ end
205
+
206
+ result || false
207
+ end
208
+
209
+ # Evaluate a single condition
210
+ # @param condition [Hash] The condition to evaluate
211
+ # @param context [Hash] The evaluation context
212
+ # @return [Boolean]
213
+ def evaluate_condition(condition, context)
214
+ attribute_value = Utils.get_nested_property(context, condition[:attribute])
215
+ Utils.evaluate_operator(attribute_value, condition[:operator], condition[:values] || [])
216
+ end
217
+
218
+ # Select a variant based on weights using consistent hashing
219
+ # @param flag_key [String] The flag key
220
+ # @param weights [Hash] Variant weights
221
+ # @param context [Hash] The evaluation context
222
+ # @return [String, nil]
223
+ def select_variant_by_weight(flag_key, weights, context)
224
+ # Calculate total weight
225
+ total_weight = 0
226
+ variants = []
227
+
228
+ weights.each do |variant, weight|
229
+ total_weight += weight
230
+ variants << { variant: variant.to_s, weight: weight }
231
+ end
232
+
233
+ return nil if total_weight.zero?
234
+
235
+ # Use consistent hashing
236
+ user_id = context.dig(:user, :id) || context.dig(:user, :email) || SecureRandom.uuid
237
+ hash = Utils.hash_key("#{flag_key}:#{user_id}")
238
+ scaled_hash = (hash.to_f / 100) * total_weight
239
+
240
+ # Select variant based on hash
241
+ accumulator = 0
242
+ variants.each do |v|
243
+ accumulator += v[:weight]
244
+ return v[:variant] if scaled_hash < accumulator
245
+ end
246
+
247
+ # Fallback to last variant
248
+ variants.last&.dig(:variant)
249
+ end
250
+
251
+ # Check if a flag exists
252
+ # @param flag_key [String] The flag key
253
+ # @return [Boolean]
254
+ def has_flag?(flag_key)
255
+ @flags.key?(flag_key)
256
+ end
257
+
258
+ # Get all flag keys
259
+ # @return [Array<String>]
260
+ def flag_keys
261
+ @flags.keys
262
+ end
263
+
264
+ # Get flag metadata
265
+ # @param flag_key [String] The flag key
266
+ # @return [Hash, nil]
267
+ def flag_metadata(flag_key)
268
+ flag = @flags[flag_key]
269
+ return nil unless flag
270
+
271
+ metadata = {
272
+ type: flag[:type],
273
+ enabled: flag[:enabled]
274
+ }
275
+
276
+ case flag[:type]
277
+ when 'multivariate'
278
+ metadata[:variants] = flag[:variants]
279
+ metadata[:default_variant] = flag[:default_variant]
280
+ when 'percentage'
281
+ metadata[:percentage] = flag[:percentage]
282
+ end
283
+
284
+ metadata
285
+ end
286
+
287
+ # Evaluate any flag type
288
+ # @param flag_key [String] The flag key
289
+ # @param context [Hash] The evaluation context
290
+ # @param default_value [Object] Default value
291
+ # @return [Object]
292
+ def evaluate(flag_key, context = {}, default_value = false)
293
+ flag = @flags[flag_key]
294
+ return default_value unless flag
295
+
296
+ case flag[:type]
297
+ when 'boolean'
298
+ evaluate_boolean(flag_key, context, default_value)
299
+ when 'multivariate'
300
+ evaluate_multivariate(flag_key, context, default_value)
301
+ when 'percentage'
302
+ evaluate_percentage(flag_key, context, default_value)
303
+ else
304
+ warn "[ToggleCraft] Unknown flag type: #{flag[:type]}"
305
+ default_value
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToggleCraft
4
+ # Shared SSE connection wrapper
5
+ # Manages a single SSE connection that can be shared by multiple SDK clients
6
+ class SharedSSEConnection
7
+ attr_reader :pool_key
8
+
9
+ def initialize(config, pool_key)
10
+ @config = config
11
+ @pool_key = pool_key
12
+ @clients = Concurrent::Set.new
13
+ @connection = nil
14
+ @last_payload = nil
15
+ @is_connecting = Concurrent::AtomicBoolean.new(false)
16
+ @connection_promise = nil
17
+ @debug = config[:debug] || false
18
+ @on_empty_callback = nil
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # Add a client to this shared connection
23
+ # @param client [Object] The ToggleCraftClient instance
24
+ # @return [void]
25
+ def add_client(client)
26
+ log "Adding client to shared connection (#{@clients.size + 1} total clients)"
27
+ @clients.add(client)
28
+
29
+ # If connection is already established, simulate initialization for new client
30
+ return unless connected? && @last_payload
31
+
32
+ log 'Connection already established - simulating initialization for new client'
33
+
34
+ # Simulate connection event
35
+ client.handle_connect if client.respond_to?(:handle_connect)
36
+
37
+ # Send cached flags
38
+ client.handle_flags_update(@last_payload) if client.respond_to?(:handle_flags_update)
39
+ end
40
+
41
+ # Remove a client from this shared connection
42
+ # @param client [Object] The ToggleCraftClient instance
43
+ # @return [void]
44
+ def remove_client(client)
45
+ log "Removing client from shared connection (#{@clients.size - 1} remaining)"
46
+ @clients.delete(client)
47
+
48
+ # If no more clients, clean up the connection
49
+ return unless @clients.empty?
50
+
51
+ log 'No more clients, closing shared connection'
52
+ cleanup
53
+ @on_empty_callback&.call
54
+ end
55
+
56
+ # Connect to the SSE server
57
+ # @param client_to_add [Object, nil] Optional client to add when connecting
58
+ # @return [void]
59
+ def connect(client_to_add = nil)
60
+ # If already connecting, wait for completion
61
+ if @is_connecting.value
62
+ log 'Connection already in progress, waiting...'
63
+ # Just add the client, connection will complete
64
+ add_client(client_to_add) if client_to_add
65
+ return
66
+ end
67
+
68
+ # If already connected, just add the client
69
+ if connected?
70
+ log 'Already connected'
71
+ add_client(client_to_add) if client_to_add
72
+ return
73
+ end
74
+
75
+ @is_connecting.make_true
76
+
77
+ begin
78
+ create_connection
79
+ add_client(client_to_add) if client_to_add
80
+ ensure
81
+ @is_connecting.make_false
82
+ end
83
+ end
84
+
85
+ # Disconnect from the SSE server
86
+ # @return [void]
87
+ def disconnect
88
+ log 'Disconnect called on shared connection'
89
+ @connection&.disconnect
90
+ end
91
+
92
+ # Force disconnect (used by pool cleanup)
93
+ # @return [void]
94
+ def force_disconnect
95
+ log 'Force disconnecting shared connection'
96
+ @connection&.disconnect
97
+ @clients.clear
98
+ end
99
+
100
+ # Reconnect to the SSE server
101
+ # @return [void]
102
+ def reconnect
103
+ log 'Reconnecting shared connection'
104
+
105
+ # Notify all clients about reconnection
106
+ @clients.each do |client|
107
+ client.emit(:reconnecting) if client.respond_to?(:emit)
108
+ end
109
+
110
+ if @connection
111
+ @connection.reconnect
112
+ else
113
+ connect
114
+ end
115
+ end
116
+
117
+ # Check if connected
118
+ # @return [Boolean]
119
+ def connected?
120
+ @connection&.connected?
121
+ end
122
+
123
+ # Get the number of connected clients
124
+ # @return [Integer]
125
+ def client_count
126
+ @clients.size
127
+ end
128
+
129
+ # Set callback for when connection becomes empty
130
+ # @param callback [Proc] Callback to invoke
131
+ # @return [void]
132
+ def on_empty(&callback)
133
+ @on_empty_callback = callback
134
+ end
135
+
136
+ # Enable or disable debug logging
137
+ # @param enabled [Boolean]
138
+ # @return [void]
139
+ def set_debug(enabled)
140
+ @debug = enabled
141
+ return unless @connection
142
+
143
+ @connection.instance_variable_set(:@options,
144
+ @connection.instance_variable_get(:@options).merge(debug: enabled))
145
+ end
146
+
147
+ private
148
+
149
+ # Create the actual SSE connection
150
+ # @return [void]
151
+ def create_connection
152
+ log 'Creating new SSE connection'
153
+
154
+ @connection = SSEConnection.new(
155
+ url: @config[:url],
156
+ sdk_key: @config[:sdk_key],
157
+ reconnect_interval: @config[:reconnect_interval],
158
+ max_reconnect_interval: @config[:max_reconnect_interval],
159
+ max_reconnect_attempts: @config[:max_reconnect_attempts],
160
+ slow_reconnect_interval: @config[:slow_reconnect_interval],
161
+ heartbeat_domain: @config[:heartbeat_domain],
162
+ debug: @debug,
163
+ on_message: method(:handle_message),
164
+ on_connect: method(:handle_connect),
165
+ on_disconnect: method(:handle_disconnect),
166
+ on_error: method(:handle_error)
167
+ )
168
+
169
+ @connection.connect
170
+ end
171
+
172
+ # Handle incoming messages from SSE
173
+ # @param payload [Hash] Message payload
174
+ # @return [void]
175
+ def handle_message(payload)
176
+ log "Broadcasting message to #{@clients.size} clients"
177
+
178
+ # Cache the last payload for new clients
179
+ @last_payload = payload if payload[:flags]
180
+
181
+ # Broadcast to all connected clients
182
+ @clients.each do |client|
183
+ next unless client.respond_to?(:handle_flags_update)
184
+
185
+ begin
186
+ client.handle_flags_update(payload)
187
+ rescue StandardError => e
188
+ warn "Error handling flags update in client: #{e.message}"
189
+ end
190
+ end
191
+ end
192
+
193
+ # Handle SSE connection
194
+ # @return [void]
195
+ def handle_connect
196
+ log 'SSE connected'
197
+
198
+ # Notify all clients
199
+ @clients.each do |client|
200
+ next unless client.respond_to?(:handle_connect)
201
+
202
+ begin
203
+ client.handle_connect
204
+ rescue StandardError => e
205
+ warn "Error handling connect in client: #{e.message}"
206
+ end
207
+ end
208
+ end
209
+
210
+ # Handle SSE disconnection
211
+ # @return [void]
212
+ def handle_disconnect
213
+ log 'SSE disconnected'
214
+
215
+ # Notify all clients
216
+ @clients.each do |client|
217
+ next unless client.respond_to?(:handle_disconnect)
218
+
219
+ begin
220
+ client.handle_disconnect
221
+ rescue StandardError => e
222
+ warn "Error handling disconnect in client: #{e.message}"
223
+ end
224
+ end
225
+ end
226
+
227
+ # Handle SSE errors
228
+ # @param error [Exception] Error that occurred
229
+ # @return [void]
230
+ def handle_error(error)
231
+ log "SSE error: #{error.message}"
232
+
233
+ # Broadcast error to all clients
234
+ @clients.each do |client|
235
+ next unless client.respond_to?(:handle_error)
236
+
237
+ begin
238
+ client.handle_error(error)
239
+ rescue StandardError => e
240
+ warn "Error handling error in client: #{e.message}"
241
+ end
242
+ end
243
+ end
244
+
245
+ # Clean up resources
246
+ # @return [void]
247
+ def cleanup
248
+ log 'Cleaning up shared connection'
249
+
250
+ @connection&.disconnect
251
+ @connection = nil
252
+ @last_payload = nil
253
+ @is_connecting.make_false
254
+ @connection_promise = nil
255
+ end
256
+
257
+ # Log debug messages
258
+ # @param message [String] Message to log
259
+ # @return [void]
260
+ def log(message)
261
+ return unless @debug
262
+
263
+ puts "[ToggleCraft SharedSSEConnection #{@pool_key}] #{message}"
264
+ end
265
+ end
266
+ end