flipper 0.28.3 → 1.4.2

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 (204) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/ci.yml +56 -10
  4. data/.github/workflows/examples.yml +54 -11
  5. data/.github/workflows/release.yml +54 -0
  6. data/.superset/config.json +4 -0
  7. data/CLAUDE.md +93 -0
  8. data/Changelog.md +1 -559
  9. data/Gemfile +17 -8
  10. data/README.md +34 -29
  11. data/Rakefile +2 -2
  12. data/benchmark/typecast_ips.rb +8 -0
  13. data/docs/images/banner.jpg +0 -0
  14. data/docs/images/flipper_cloud.png +0 -0
  15. data/examples/cloud/app.ru +12 -0
  16. data/examples/cloud/backoff_policy.rb +13 -0
  17. data/examples/cloud/basic.rb +22 -0
  18. data/examples/cloud/cloud_setup.rb +20 -0
  19. data/examples/cloud/forked.rb +36 -0
  20. data/examples/cloud/import.rb +17 -0
  21. data/examples/cloud/poll_interval/README.md +111 -0
  22. data/examples/cloud/poll_interval/client.rb +108 -0
  23. data/examples/cloud/poll_interval/server.rb +98 -0
  24. data/examples/cloud/threaded.rb +33 -0
  25. data/examples/dsl.rb +0 -14
  26. data/examples/expressions.rb +237 -0
  27. data/examples/strict.rb +18 -0
  28. data/exe/flipper +5 -0
  29. data/flipper-cloud.gemspec +19 -0
  30. data/flipper.gemspec +8 -4
  31. data/lib/flipper/actor.rb +6 -3
  32. data/lib/flipper/adapter.rb +27 -1
  33. data/lib/flipper/adapter_builder.rb +44 -0
  34. data/lib/flipper/adapters/actor_limit.rb +54 -0
  35. data/lib/flipper/adapters/cache_base.rb +161 -0
  36. data/lib/flipper/adapters/dual_write.rb +7 -5
  37. data/lib/flipper/adapters/failover.rb +9 -7
  38. data/lib/flipper/adapters/failsafe.rb +2 -6
  39. data/lib/flipper/adapters/http/client.rb +53 -14
  40. data/lib/flipper/adapters/http/error.rb +2 -2
  41. data/lib/flipper/adapters/http.rb +56 -16
  42. data/lib/flipper/adapters/instrumented.rb +2 -6
  43. data/lib/flipper/adapters/memoizable.rb +17 -22
  44. data/lib/flipper/adapters/memory.rb +5 -7
  45. data/lib/flipper/adapters/operation_logger.rb +18 -92
  46. data/lib/flipper/adapters/poll.rb +16 -3
  47. data/lib/flipper/adapters/pstore.rb +18 -12
  48. data/lib/flipper/adapters/read_only.rb +8 -41
  49. data/lib/flipper/adapters/strict.rb +75 -0
  50. data/lib/flipper/adapters/sync/feature_synchronizer.rb +14 -1
  51. data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
  52. data/lib/flipper/adapters/sync.rb +7 -7
  53. data/lib/flipper/adapters/wrapper.rb +54 -0
  54. data/lib/flipper/cli.rb +314 -0
  55. data/lib/flipper/cloud/configuration.rb +267 -0
  56. data/lib/flipper/cloud/dsl.rb +27 -0
  57. data/lib/flipper/cloud/message_verifier.rb +95 -0
  58. data/lib/flipper/cloud/middleware.rb +63 -0
  59. data/lib/flipper/cloud/migrate.rb +71 -0
  60. data/lib/flipper/cloud/routes.rb +14 -0
  61. data/lib/flipper/cloud/telemetry/backoff_policy.rb +96 -0
  62. data/lib/flipper/cloud/telemetry/instrumenter.rb +22 -0
  63. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  64. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  65. data/lib/flipper/cloud/telemetry/submitter.rb +100 -0
  66. data/lib/flipper/cloud/telemetry.rb +191 -0
  67. data/lib/flipper/cloud.rb +54 -0
  68. data/lib/flipper/configuration.rb +25 -4
  69. data/lib/flipper/dsl.rb +48 -43
  70. data/lib/flipper/engine.rb +102 -0
  71. data/lib/flipper/export.rb +0 -2
  72. data/lib/flipper/exporters/json/export.rb +1 -1
  73. data/lib/flipper/exporters/json/v1.rb +1 -1
  74. data/lib/flipper/expression/builder.rb +73 -0
  75. data/lib/flipper/expression/constant.rb +25 -0
  76. data/lib/flipper/expression.rb +71 -0
  77. data/lib/flipper/expressions/all.rb +9 -0
  78. data/lib/flipper/expressions/any.rb +9 -0
  79. data/lib/flipper/expressions/boolean.rb +9 -0
  80. data/lib/flipper/expressions/comparable.rb +13 -0
  81. data/lib/flipper/expressions/equal.rb +9 -0
  82. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  83. data/lib/flipper/expressions/greater_than.rb +9 -0
  84. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  85. data/lib/flipper/expressions/less_than.rb +9 -0
  86. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  87. data/lib/flipper/expressions/not_equal.rb +9 -0
  88. data/lib/flipper/expressions/now.rb +9 -0
  89. data/lib/flipper/expressions/number.rb +9 -0
  90. data/lib/flipper/expressions/percentage.rb +9 -0
  91. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  92. data/lib/flipper/expressions/property.rb +9 -0
  93. data/lib/flipper/expressions/random.rb +9 -0
  94. data/lib/flipper/expressions/string.rb +9 -0
  95. data/lib/flipper/expressions/time.rb +16 -0
  96. data/lib/flipper/feature.rb +63 -1
  97. data/lib/flipper/gate.rb +2 -1
  98. data/lib/flipper/gate_values.rb +5 -2
  99. data/lib/flipper/gates/expression.rb +75 -0
  100. data/lib/flipper/instrumentation/log_subscriber.rb +13 -5
  101. data/lib/flipper/instrumentation/statsd.rb +4 -2
  102. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  103. data/lib/flipper/instrumentation/subscriber.rb +0 -4
  104. data/lib/flipper/metadata.rb +8 -1
  105. data/lib/flipper/middleware/memoizer.rb +30 -14
  106. data/lib/flipper/model/active_record.rb +23 -0
  107. data/lib/flipper/poller.rb +54 -12
  108. data/lib/flipper/serializers/gzip.rb +22 -0
  109. data/lib/flipper/serializers/json.rb +17 -0
  110. data/lib/flipper/spec/shared_adapter_specs.rb +82 -63
  111. data/lib/flipper/test/shared_adapter_test.rb +77 -58
  112. data/lib/flipper/test_help.rb +43 -0
  113. data/lib/flipper/typecast.rb +37 -9
  114. data/lib/flipper/types/percentage.rb +1 -1
  115. data/lib/flipper/version.rb +11 -1
  116. data/lib/flipper.rb +61 -8
  117. data/lib/generators/flipper/setup_generator.rb +68 -0
  118. data/lib/generators/flipper/templates/initializer.rb +45 -0
  119. data/lib/generators/flipper/templates/update/migrations/01_create_flipper_tables.rb.erb +22 -0
  120. data/lib/generators/flipper/templates/update/migrations/02_change_flipper_gates_value_to_text.rb.erb +18 -0
  121. data/lib/generators/flipper/update_generator.rb +35 -0
  122. data/package-lock.json +41 -0
  123. data/package.json +10 -0
  124. data/spec/fixtures/environment.rb +1 -0
  125. data/spec/flipper/adapter_builder_spec.rb +72 -0
  126. data/spec/flipper/adapter_spec.rb +21 -0
  127. data/spec/flipper/adapters/actor_limit_spec.rb +75 -0
  128. data/spec/flipper/adapters/dual_write_spec.rb +15 -2
  129. data/spec/flipper/adapters/failover_spec.rb +12 -0
  130. data/spec/flipper/adapters/http/client_spec.rb +61 -0
  131. data/spec/flipper/adapters/http_spec.rb +375 -74
  132. data/spec/flipper/adapters/instrumented_spec.rb +1 -1
  133. data/spec/flipper/adapters/memoizable_spec.rb +21 -21
  134. data/spec/flipper/adapters/operation_logger_spec.rb +2 -2
  135. data/spec/flipper/adapters/poll_spec.rb +41 -0
  136. data/spec/flipper/adapters/read_only_spec.rb +32 -17
  137. data/spec/flipper/adapters/strict_spec.rb +122 -0
  138. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +39 -0
  139. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  140. data/spec/flipper/adapters/sync_spec.rb +13 -0
  141. data/spec/flipper/cli_spec.rb +217 -0
  142. data/spec/flipper/cloud/configuration_spec.rb +251 -0
  143. data/spec/flipper/cloud/dsl_spec.rb +82 -0
  144. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  145. data/spec/flipper/cloud/middleware_spec.rb +289 -0
  146. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  147. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +107 -0
  148. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  149. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  150. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  151. data/spec/flipper/cloud/telemetry_spec.rb +208 -0
  152. data/spec/flipper/cloud_spec.rb +186 -0
  153. data/spec/flipper/configuration_spec.rb +17 -0
  154. data/spec/flipper/dsl_spec.rb +34 -73
  155. data/spec/flipper/engine_spec.rb +374 -0
  156. data/spec/flipper/exporters/json/v1_spec.rb +3 -3
  157. data/spec/flipper/expression/builder_spec.rb +248 -0
  158. data/spec/flipper/expression_spec.rb +188 -0
  159. data/spec/flipper/expressions/all_spec.rb +15 -0
  160. data/spec/flipper/expressions/any_spec.rb +15 -0
  161. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  162. data/spec/flipper/expressions/equal_spec.rb +24 -0
  163. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  164. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  165. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  166. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  167. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  168. data/spec/flipper/expressions/now_spec.rb +11 -0
  169. data/spec/flipper/expressions/number_spec.rb +21 -0
  170. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  171. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  172. data/spec/flipper/expressions/property_spec.rb +13 -0
  173. data/spec/flipper/expressions/random_spec.rb +9 -0
  174. data/spec/flipper/expressions/string_spec.rb +11 -0
  175. data/spec/flipper/expressions/time_spec.rb +29 -0
  176. data/spec/flipper/feature_spec.rb +380 -10
  177. data/spec/flipper/gate_values_spec.rb +2 -2
  178. data/spec/flipper/gates/expression_spec.rb +190 -0
  179. data/spec/flipper/identifier_spec.rb +4 -5
  180. data/spec/flipper/instrumentation/log_subscriber_spec.rb +10 -2
  181. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +16 -2
  182. data/spec/flipper/middleware/memoizer_spec.rb +107 -7
  183. data/spec/flipper/model/active_record_spec.rb +72 -0
  184. data/spec/flipper/poller_spec.rb +347 -4
  185. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  186. data/spec/flipper/serializers/json_spec.rb +13 -0
  187. data/spec/flipper/typecast_spec.rb +43 -7
  188. data/spec/flipper/types/actor_spec.rb +18 -1
  189. data/spec/flipper_integration_spec.rb +247 -16
  190. data/spec/flipper_spec.rb +92 -29
  191. data/spec/spec_helper.rb +24 -17
  192. data/spec/support/actor_names.yml +1 -0
  193. data/spec/support/fail_on_output.rb +8 -0
  194. data/spec/support/fake_backoff_policy.rb +15 -0
  195. data/spec/support/spec_helpers.rb +34 -8
  196. data/test/adapters/actor_limit_test.rb +20 -0
  197. data/test_rails/generators/flipper/setup_generator_test.rb +69 -0
  198. data/test_rails/generators/flipper/update_generator_test.rb +96 -0
  199. data/test_rails/helper.rb +22 -2
  200. data/test_rails/system/test_help_test.rb +52 -0
  201. metadata +139 -75
  202. data/.tool-versions +0 -1
  203. data/lib/flipper/railtie.rb +0 -47
  204. data/spec/flipper/railtie_spec.rb +0 -109
@@ -2,7 +2,7 @@ require 'flipper/adapters/memoizable'
2
2
  require 'flipper/adapters/operation_logger'
3
3
 
4
4
  RSpec.describe Flipper::Adapters::Memoizable do
5
- let(:features_key) { described_class::FeaturesKey }
5
+ let(:features_key) { :flipper_features }
6
6
  let(:adapter) { Flipper::Adapters::Memory.new }
7
7
  let(:flipper) { Flipper.new(adapter) }
8
8
  let(:cache) { {} }
@@ -54,7 +54,7 @@ RSpec.describe Flipper::Adapters::Memoizable do
54
54
  it 'memoizes feature' do
55
55
  feature = flipper[:stats]
56
56
  result = subject.get(feature)
57
- expect(cache[described_class.key_for(feature.key)]).to be(result)
57
+ expect(cache["feature/#{feature.key}"]).to be(result)
58
58
  end
59
59
  end
60
60
 
@@ -83,8 +83,8 @@ RSpec.describe Flipper::Adapters::Memoizable do
83
83
  features = names.map { |name| flipper[name] }
84
84
  results = subject.get_multi(features)
85
85
  features.each do |feature|
86
- expect(cache[described_class.key_for(feature.key)]).not_to be(nil)
87
- expect(cache[described_class.key_for(feature.key)]).to be(results[feature.key])
86
+ expect(cache["feature/#{feature.key}"]).not_to be(nil)
87
+ expect(cache["feature/#{feature.key}"]).to be(results[feature.key])
88
88
  end
89
89
  end
90
90
  end
@@ -115,10 +115,10 @@ RSpec.describe Flipper::Adapters::Memoizable do
115
115
  features = names.map { |name| flipper[name].tap(&:enable) }
116
116
  results = subject.get_all
117
117
  features.each do |feature|
118
- expect(cache[described_class.key_for(feature.key)]).not_to be(nil)
119
- expect(cache[described_class.key_for(feature.key)]).to be(results[feature.key])
118
+ expect(cache["feature/#{feature.key}"]).not_to be(nil)
119
+ expect(cache["feature/#{feature.key}"]).to be(results[feature.key])
120
120
  end
121
- expect(cache[subject.class::FeaturesKey]).to eq(names.map(&:to_s).to_set)
121
+ expect(cache[:flipper_features]).to eq(names.map(&:to_s).to_set)
122
122
  end
123
123
 
124
124
  it 'only calls get_all once for memoized adapter' do
@@ -188,9 +188,9 @@ RSpec.describe Flipper::Adapters::Memoizable do
188
188
  it 'unmemoizes feature' do
189
189
  feature = flipper[:stats]
190
190
  gate = feature.gate(:boolean)
191
- cache[described_class.key_for(feature.key)] = { some: 'thing' }
192
- subject.enable(feature, gate, flipper.bool)
193
- expect(cache[described_class.key_for(feature.key)]).to be_nil
191
+ cache["feature/#{feature.key}"] = { some: 'thing' }
192
+ subject.enable(feature, gate, Flipper::Types::Boolean.new)
193
+ expect(cache["feature/#{feature.key}"]).to be_nil
194
194
  end
195
195
  end
196
196
 
@@ -202,8 +202,8 @@ RSpec.describe Flipper::Adapters::Memoizable do
202
202
  it 'returns result' do
203
203
  feature = flipper[:stats]
204
204
  gate = feature.gate(:boolean)
205
- result = subject.enable(feature, gate, flipper.bool)
206
- adapter_result = adapter.enable(feature, gate, flipper.bool)
205
+ result = subject.enable(feature, gate, Flipper::Types::Boolean.new)
206
+ adapter_result = adapter.enable(feature, gate, Flipper::Types::Boolean.new)
207
207
  expect(result).to eq(adapter_result)
208
208
  end
209
209
  end
@@ -218,9 +218,9 @@ RSpec.describe Flipper::Adapters::Memoizable do
218
218
  it 'unmemoizes feature' do
219
219
  feature = flipper[:stats]
220
220
  gate = feature.gate(:boolean)
221
- cache[described_class.key_for(feature.key)] = { some: 'thing' }
222
- subject.disable(feature, gate, flipper.bool)
223
- expect(cache[described_class.key_for(feature.key)]).to be_nil
221
+ cache["feature/#{feature.key}"] = { some: 'thing' }
222
+ subject.disable(feature, gate, Flipper::Types::Boolean.new)
223
+ expect(cache["feature/#{feature.key}"]).to be_nil
224
224
  end
225
225
  end
226
226
 
@@ -232,8 +232,8 @@ RSpec.describe Flipper::Adapters::Memoizable do
232
232
  it 'returns result' do
233
233
  feature = flipper[:stats]
234
234
  gate = feature.gate(:boolean)
235
- result = subject.disable(feature, gate, flipper.bool)
236
- adapter_result = adapter.disable(feature, gate, flipper.bool)
235
+ result = subject.disable(feature, gate, Flipper::Types::Boolean.new)
236
+ adapter_result = adapter.disable(feature, gate, Flipper::Types::Boolean.new)
237
237
  expect(result).to eq(adapter_result)
238
238
  end
239
239
  end
@@ -332,9 +332,9 @@ RSpec.describe Flipper::Adapters::Memoizable do
332
332
 
333
333
  it 'unmemoizes the feature' do
334
334
  feature = flipper[:stats]
335
- cache[described_class.key_for(feature.key)] = { some: 'thing' }
335
+ cache["feature/#{feature.key}"] = { some: 'thing' }
336
336
  subject.remove(feature)
337
- expect(cache[described_class.key_for(feature.key)]).to be_nil
337
+ expect(cache["feature/#{feature.key}"]).to be_nil
338
338
  end
339
339
  end
340
340
 
@@ -357,9 +357,9 @@ RSpec.describe Flipper::Adapters::Memoizable do
357
357
 
358
358
  it 'unmemoizes feature' do
359
359
  feature = flipper[:stats]
360
- cache[described_class.key_for(feature.key)] = { some: 'thing' }
360
+ cache["feature/#{feature.key}"] = { some: 'thing' }
361
361
  subject.clear(feature)
362
- expect(cache[described_class.key_for(feature.key)]).to be_nil
362
+ expect(cache["feature/#{feature.key}"]).to be_nil
363
363
  end
364
364
  end
365
365
 
@@ -37,7 +37,7 @@ RSpec.describe Flipper::Adapters::OperationLogger do
37
37
  before do
38
38
  @feature = flipper[:stats]
39
39
  @gate = @feature.gate(:boolean)
40
- @thing = flipper.bool
40
+ @thing = Flipper::Types::Boolean.new
41
41
  @result = subject.enable(@feature, @gate, @thing)
42
42
  end
43
43
 
@@ -54,7 +54,7 @@ RSpec.describe Flipper::Adapters::OperationLogger do
54
54
  before do
55
55
  @feature = flipper[:stats]
56
56
  @gate = @feature.gate(:boolean)
57
- @thing = flipper.bool
57
+ @thing = Flipper::Types::Boolean.new
58
58
  @result = subject.disable(@feature, @gate, @thing)
59
59
  end
60
60
 
@@ -0,0 +1,41 @@
1
+ require 'flipper/adapters/poll'
2
+
3
+ RSpec.describe Flipper::Adapters::Poll do
4
+ let(:remote_adapter) {
5
+ adapter = Flipper::Adapters::Memory.new(threadsafe: true)
6
+ flipper = Flipper.new(adapter)
7
+ flipper.enable(:search)
8
+ flipper.enable(:analytics)
9
+ adapter
10
+ }
11
+ let(:local_adapter) { Flipper::Adapters::Memory.new(threadsafe: true) }
12
+ let(:poller) {
13
+ Flipper::Poller.get("for_spec", {
14
+ start_automatically: false,
15
+ remote_adapter: remote_adapter,
16
+ })
17
+ }
18
+
19
+ it "syncs in main thread if local adapter is empty" do
20
+ instance = described_class.new(poller, local_adapter)
21
+ instance.features # call something to force sync
22
+ expect(local_adapter.features).to eq(remote_adapter.features)
23
+ end
24
+
25
+ it "does not sync in main thread if local adapter is not empty" do
26
+ # make local not empty by importing remote
27
+ flipper = Flipper.new(local_adapter)
28
+ flipper.import(remote_adapter)
29
+
30
+ # make a fake poller to verify calls
31
+ poller = double("Poller", last_synced_at: Concurrent::AtomicFixnum.new(0))
32
+ expect(poller).to receive(:start).twice
33
+ expect(poller).not_to receive(:sync)
34
+
35
+ # create new instance and call something to force sync
36
+ instance = described_class.new(poller, local_adapter)
37
+ instance.features # call something to force sync
38
+
39
+ expect(local_adapter.features).to eq(remote_adapter.features)
40
+ end
41
+ end
@@ -5,11 +5,12 @@ RSpec.describe Flipper::Adapters::ReadOnly do
5
5
  let(:flipper) { Flipper.new(subject) }
6
6
  let(:feature) { flipper[:stats] }
7
7
 
8
- let(:boolean_gate) { feature.gate(:boolean) }
9
- let(:group_gate) { feature.gate(:group) }
10
- let(:actor_gate) { feature.gate(:actor) }
11
- let(:actors_gate) { feature.gate(:percentage_of_actors) }
12
- let(:time_gate) { feature.gate(:percentage_of_time) }
8
+ let(:boolean_gate) { feature.gate(:boolean) }
9
+ let(:group_gate) { feature.gate(:group) }
10
+ let(:actor_gate) { feature.gate(:actor) }
11
+ let(:expression_gate) { feature.gate(:expression) }
12
+ let(:actors_gate) { feature.gate(:percentage_of_actors) }
13
+ let(:time_gate) { feature.gate(:percentage_of_time) }
13
14
 
14
15
  subject { described_class.new(adapter) }
15
16
 
@@ -41,18 +42,28 @@ RSpec.describe Flipper::Adapters::ReadOnly do
41
42
  end
42
43
 
43
44
  it 'can get feature' do
45
+ expression = Flipper.property(:plan).eq("basic")
44
46
  actor22 = Flipper::Actor.new('22')
45
- adapter.enable(feature, boolean_gate, flipper.boolean)
47
+ adapter.enable(feature, boolean_gate, Flipper::Types::Boolean.new)
46
48
  adapter.enable(feature, group_gate, flipper.group(:admins))
47
- adapter.enable(feature, actor_gate, flipper.actor(actor22))
48
- adapter.enable(feature, actors_gate, flipper.actors(25))
49
- adapter.enable(feature, time_gate, flipper.time(45))
50
-
51
- expect(subject.get(feature)).to eq(boolean: 'true',
52
- groups: Set['admins'],
53
- actors: Set['22'],
54
- percentage_of_actors: '25',
55
- percentage_of_time: '45')
49
+ adapter.enable(feature, actor_gate, Flipper::Types::Actor.new(actor22))
50
+ adapter.enable(feature, actors_gate, Flipper::Types::PercentageOfActors.new(25))
51
+ adapter.enable(feature, time_gate, Flipper::Types::PercentageOfTime.new(45))
52
+ adapter.enable(feature, expression_gate, expression)
53
+
54
+ expect(subject.get(feature)).to eq({
55
+ boolean: 'true',
56
+ groups: Set['admins'],
57
+ actors: Set['22'],
58
+ expression: {
59
+ "Equal" => [
60
+ {"Property" => ["plan"]},
61
+ "basic",
62
+ ]
63
+ },
64
+ percentage_of_actors: '25',
65
+ percentage_of_time: '45',
66
+ })
56
67
  end
57
68
 
58
69
  it 'can get features' do
@@ -61,6 +72,10 @@ RSpec.describe Flipper::Adapters::ReadOnly do
61
72
  expect(subject.features).to eq(Set['stats'])
62
73
  end
63
74
 
75
+ it 'is configured as read only' do
76
+ expect(subject.read_only?).to eq(true)
77
+ end
78
+
64
79
  it 'raises error on add' do
65
80
  expect { subject.add(feature) }.to raise_error(Flipper::Adapters::ReadOnly::WriteAttempted)
66
81
  end
@@ -74,12 +89,12 @@ RSpec.describe Flipper::Adapters::ReadOnly do
74
89
  end
75
90
 
76
91
  it 'raises error on enable' do
77
- expect { subject.enable(feature, boolean_gate, flipper.boolean) }
92
+ expect { subject.enable(feature, boolean_gate, Flipper::Types::Boolean.new) }
78
93
  .to raise_error(Flipper::Adapters::ReadOnly::WriteAttempted)
79
94
  end
80
95
 
81
96
  it 'raises error on disable' do
82
- expect { subject.disable(feature, boolean_gate, flipper.boolean) }
97
+ expect { subject.disable(feature, boolean_gate, Flipper::Types::Boolean.new) }
83
98
  .to raise_error(Flipper::Adapters::ReadOnly::WriteAttempted)
84
99
  end
85
100
  end
@@ -0,0 +1,122 @@
1
+ RSpec.describe Flipper::Adapters::Strict do
2
+ let(:flipper) { Flipper.new(subject) }
3
+ let(:feature) { flipper[:unknown] }
4
+
5
+ it_should_behave_like 'a flipper adapter' do
6
+ subject { described_class.new(Flipper::Adapters::Memory.new, :noop) }
7
+ end
8
+
9
+ [true, :raise].each do |handler|
10
+ context "handler = #{handler}" do
11
+ subject { described_class.new(Flipper::Adapters::Memory.new, handler) }
12
+
13
+ context "#get" do
14
+ it "raises an error for unknown feature" do
15
+ expect { subject.get(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
16
+ end
17
+ end
18
+
19
+ context "#get_multi" do
20
+ it "raises an error for unknown feature" do
21
+ expect { subject.get_multi([feature]) }.to raise_error(Flipper::Adapters::Strict::NotFound)
22
+ end
23
+ end
24
+
25
+ context "#add" do
26
+ it "raises an error for unknown feature" do
27
+ expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ context "handler = :warn" do
34
+ subject { described_class.new(Flipper::Adapters::Memory.new, :warn) }
35
+
36
+ context "#get" do
37
+ it "warns for unknown feature" do
38
+ expect(capture_output { subject.get(feature) }).to match(/Could not find feature "unknown"/)
39
+ end
40
+ end
41
+
42
+ context "#get_multi" do
43
+ it "warns for unknown feature" do
44
+ expect(capture_output { subject.get_multi([feature]) }).to match(/Could not find feature "unknown"/)
45
+ end
46
+ end
47
+
48
+ context "#add" do
49
+ it "warns for unknown feature" do
50
+ expect(capture_output { subject.add(feature) }).to match(/Could not find feature "unknown"/)
51
+ end
52
+ end
53
+ end
54
+
55
+ context "handler = Block" do
56
+ let(:unknown_features) { [] }
57
+ subject do
58
+ described_class.new(Flipper::Adapters::Memory.new) { |feature| unknown_features << feature.key}
59
+ end
60
+
61
+
62
+ context "#get" do
63
+ it "calls block for unknown feature" do
64
+ subject.get(feature)
65
+ expect(unknown_features).to eq(["unknown"])
66
+ end
67
+ end
68
+
69
+ context "#get_multi" do
70
+ it "calls block for unknown feature" do
71
+ subject.get_multi([flipper[:foo], flipper[:bar]])
72
+ expect(unknown_features).to eq(["foo", "bar"])
73
+ end
74
+ end
75
+
76
+ context "#add" do
77
+ it "calls block for unknown feature" do
78
+ subject.add(feature)
79
+ expect(unknown_features).to eq(["unknown"])
80
+ end
81
+ end
82
+ end
83
+
84
+ describe ".with_sync_mode" do
85
+ subject { described_class.new(Flipper::Adapters::Memory.new, :raise) }
86
+
87
+ it "bypasses strict checks for add" do
88
+ described_class.with_sync_mode do
89
+ expect { subject.add(feature) }.not_to raise_error
90
+ end
91
+ end
92
+
93
+ it "bypasses strict checks for get" do
94
+ described_class.with_sync_mode do
95
+ expect { subject.get(feature) }.not_to raise_error
96
+ end
97
+ end
98
+
99
+ it "bypasses strict checks for get_multi" do
100
+ described_class.with_sync_mode do
101
+ expect { subject.get_multi([feature]) }.not_to raise_error
102
+ end
103
+ end
104
+
105
+ it "restores previous sync mode after block" do
106
+ described_class.with_sync_mode do
107
+ # inside sync mode
108
+ end
109
+ expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
110
+ end
111
+
112
+ it "restores previous sync mode even on error" do
113
+ begin
114
+ described_class.with_sync_mode do
115
+ raise "boom"
116
+ end
117
+ rescue RuntimeError
118
+ end
119
+ expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
120
+ end
121
+ end
122
+ end
@@ -8,6 +8,8 @@ RSpec.describe Flipper::Adapters::Sync::FeatureSynchronizer do
8
8
  end
9
9
  let(:flipper) { Flipper.new(adapter) }
10
10
  let(:feature) { flipper[:search] }
11
+ let(:plan_expression) { Flipper.property(:plan).eq("basic") }
12
+ let(:age_expression) { Flipper.property(:age).gte(21) }
11
13
 
12
14
  context "when remote disabled" do
13
15
  let(:remote) { Flipper::GateValues.new({}) }
@@ -63,6 +65,7 @@ RSpec.describe Flipper::Adapters::Sync::FeatureSynchronizer do
63
65
  boolean: nil,
64
66
  actors: Set["1"],
65
67
  groups: Set["staff"],
68
+ expression: plan_expression.value,
66
69
  percentage_of_time: 10,
67
70
  percentage_of_actors: 15,
68
71
  }
@@ -74,10 +77,46 @@ RSpec.describe Flipper::Adapters::Sync::FeatureSynchronizer do
74
77
  expect(local_gate_values_hash.fetch(:boolean)).to be(nil)
75
78
  expect(local_gate_values_hash.fetch(:actors)).to eq(Set["1"])
76
79
  expect(local_gate_values_hash.fetch(:groups)).to eq(Set["staff"])
80
+ expect(local_gate_values_hash.fetch(:expression)).to eq(plan_expression.value)
77
81
  expect(local_gate_values_hash.fetch(:percentage_of_time)).to eq("10")
78
82
  expect(local_gate_values_hash.fetch(:percentage_of_actors)).to eq("15")
79
83
  end
80
84
 
85
+ it "updates expression when remote is updated" do
86
+ any_expression = Flipper.any(plan_expression, age_expression)
87
+ remote = Flipper::GateValues.new(expression: any_expression.value)
88
+ feature.enable_expression(age_expression)
89
+ adapter.reset
90
+
91
+ described_class.new(feature, feature.gate_values, remote).call
92
+
93
+ expect(feature.expression_value).to eq(any_expression.value)
94
+ expect_only_enable
95
+ end
96
+
97
+ it "does nothing to expression if in sync" do
98
+ remote = Flipper::GateValues.new(expression: plan_expression.value)
99
+ feature.enable_expression(plan_expression)
100
+ adapter.reset
101
+
102
+ described_class.new(feature, feature.gate_values, remote).call
103
+
104
+ expect(feature.expression_value).to eq(plan_expression.value)
105
+ expect_no_enable_or_disable
106
+ end
107
+
108
+ it "updates expression when remote conditionally enabled but expression is nil" do
109
+ remote = Flipper::GateValues.new(expression: nil, actors: Set["1"])
110
+ feature.enable_expression(plan_expression)
111
+ feature.enable_actor(Flipper::Actor.new("1"))
112
+ adapter.reset
113
+
114
+ described_class.new(feature, feature.gate_values, remote).call
115
+
116
+ expect(feature.expression_value).to eq(nil)
117
+ expect_only_disable
118
+ end
119
+
81
120
  it "adds remotely added actors" do
82
121
  remote = Flipper::GateValues.new(actors: Set["1", "2"])
83
122
  feature.enable_actor(Flipper::Actor.new("1"))
@@ -1,4 +1,5 @@
1
1
  require "flipper/adapters/memory"
2
+ require "flipper/adapters/actor_limit"
2
3
  require "flipper/instrumenters/memory"
3
4
  require "flipper/adapters/sync/synchronizer"
4
5
 
@@ -84,5 +85,91 @@ RSpec.describe Flipper::Adapters::Sync::Synchronizer do
84
85
 
85
86
  expect(local_flipper.features.map(&:key)).to eq([])
86
87
  end
88
+
89
+ it 'emits feature_operation.flipper events when syncing' do
90
+ remote_flipper.enable(:search)
91
+
92
+ subject.call
93
+
94
+ events = instrumenter.events_by_name("feature_operation.flipper")
95
+ enable_events = events.select { |e| e.payload[:operation] == :enable }
96
+ expect(enable_events).not_to be_empty
97
+
98
+ feature_names = enable_events.map { |e| e.payload[:feature_name].to_s }
99
+ expect(feature_names).to include("search")
100
+ end
101
+
102
+ it 'emits feature_operation.flipper events when adding features' do
103
+ remote_flipper.add(:new_feature)
104
+
105
+ subject.call
106
+
107
+ events = instrumenter.events_by_name("feature_operation.flipper")
108
+ add_events = events.select { |e| e.payload[:operation] == :add }
109
+ expect(add_events).not_to be_empty
110
+
111
+ feature_names = add_events.map { |e| e.payload[:feature_name].to_s }
112
+ expect(feature_names).to include("new_feature")
113
+ end
114
+
115
+ it 'emits feature_operation.flipper events when removing features' do
116
+ local_flipper.add(:old_feature)
117
+
118
+ subject.call
119
+
120
+ events = instrumenter.events_by_name("feature_operation.flipper")
121
+ remove_events = events.select { |e| e.payload[:operation] == :remove }
122
+ expect(remove_events).not_to be_empty
123
+
124
+ feature_names = remove_events.map { |e| e.payload[:feature_name].to_s }
125
+ expect(feature_names).to include("old_feature")
126
+ end
127
+ end
128
+
129
+ context 'with ActorLimit adapter wrapping local' do
130
+ let(:limit) { 10 }
131
+ let(:limited_local) { Flipper::Adapters::ActorLimit.new(local, limit) }
132
+ let(:limited_local_flipper) { Flipper.new(limited_local) }
133
+
134
+ subject { described_class.new(limited_local, remote, instrumenter: instrumenter) }
135
+
136
+ it 'syncs actors even when remote has more actors than local limit' do
137
+ # Remote has more actors than local limit allows
138
+ 20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
139
+
140
+ # This should NOT raise - sync should bypass actor limits
141
+ expect { subject.call }.not_to raise_error
142
+
143
+ # All actors should be synced
144
+ expect(limited_local_flipper[:search].actors_value.size).to eq(20)
145
+ end
146
+
147
+ it 'syncs new actors added to remote after initial sync' do
148
+ # Initial state: remote has 20 actors, local limit is 10
149
+ 20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
150
+
151
+ # First sync - should work despite exceeding limit
152
+ subject.call
153
+ expect(limited_local_flipper[:search].actors_value.size).to eq(20)
154
+
155
+ # Add a 21st actor to remote (simulating Cloud adding a new actor)
156
+ remote_flipper[:search].enable_actor Flipper::Actor.new("User;20")
157
+
158
+ # Sync again - should pick up the new actor
159
+ expect { subject.call }.not_to raise_error
160
+ expect(limited_local_flipper[:search].actors_value.size).to eq(21)
161
+ expect(limited_local_flipper[:search].actors_value).to include("User;20")
162
+ end
163
+
164
+ it 'still enforces limit for direct enable operations' do
165
+ # First sync 20 actors from remote
166
+ 20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
167
+ subject.call
168
+
169
+ # Direct enable should still fail because we're over limit
170
+ expect {
171
+ limited_local_flipper[:search].enable_actor Flipper::Actor.new("User;new")
172
+ }.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded)
173
+ end
87
174
  end
88
175
  end
@@ -197,4 +197,17 @@ RSpec.describe Flipper::Adapters::Sync do
197
197
  expect(remote_adapter).to receive(:get_all).and_raise(exception)
198
198
  expect { subject.get_all }.not_to raise_error
199
199
  end
200
+
201
+ describe '#adapter_stack' do
202
+ it 'returns the tree representation' do
203
+ expect(subject.adapter_stack).to eq("sync(local: operation_logger -> memory, remote: operation_logger -> memory)")
204
+ end
205
+
206
+ it 'shows nested adapters in the tree' do
207
+ memory = Flipper::Adapters::Memory.new
208
+ strict = Flipper::Adapters::Strict.new(Flipper::Adapters::Memory.new)
209
+ adapter = described_class.new(memory, strict, interval: 1)
210
+ expect(adapter.adapter_stack).to eq("sync(local: memory, remote: strict -> memory)")
211
+ end
212
+ end
200
213
  end