magick-feature-flags 1.4.3 → 1.6.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.
@@ -87,15 +87,18 @@ if defined?(Rails)
87
87
  # Supports both config/features.rb and config/initializers/features.rb
88
88
  config.after_initialize do
89
89
  # Try config/features.rb first (recommended location)
90
+ # definition_mode suppresses audit/version recording: boot replays
91
+ # the declarative definitions in every container, and recording
92
+ # those would flood history with identical snapshots.
90
93
  features_file = Rails.root.join('config', 'features.rb')
91
94
  if File.exist?(features_file)
92
- load features_file
95
+ Magick.definition_mode { load features_file }
93
96
  else
94
97
  # Fallback to config/initializers/features.rb (already loaded by Rails, but check anyway)
95
98
  initializer_file = Rails.root.join('config', 'initializers', 'features.rb')
96
99
  if File.exist?(initializer_file) && !defined?(Magick::Rails::FeaturesLoaded)
97
100
  # Only load if not already loaded (Rails may have already loaded it)
98
- load initializer_file
101
+ Magick.definition_mode { load initializer_file }
99
102
  end
100
103
  end
101
104
  begin
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'time'
5
+
6
+ module Magick
7
+ # Translates between the internal targeting hash and the wire-format
8
+ # targeting payload exchanged with control planes (panel contract):
9
+ #
10
+ # GET -> Feature#as_json emits `serialize(targeting)` — string keys,
11
+ # arrays of strings, floats; the "targeting" key is always
12
+ # present ({} = no targeting).
13
+ # PATCH -> Feature#replace_targeting runs the inbound payload through
14
+ # `normalize` — lenient about aliases/scalars/key types, strict
15
+ # about vocabulary and values (all-or-nothing).
16
+ #
17
+ # The internal-only :variants key never crosses the wire in either
18
+ # direction: `serialize` drops it, `normalize` rejects it.
19
+ module TargetingPayload
20
+ # Inclusion keys are singular, exclusion keys plural — mirrors the
21
+ # internal @targeting layout, which is also the persisted layout.
22
+ ARRAY_KEYS = %i[
23
+ user group role tag ip_address
24
+ excluded_users excluded_groups excluded_roles excluded_tags excluded_ip_addresses
25
+ ].freeze
26
+ PERCENTAGE_KEYS = %i[percentage_users percentage_requests].freeze
27
+ STRUCTURED_KEYS = %i[date_range custom_attributes complex_conditions].freeze
28
+ CANONICAL_KEYS = (ARRAY_KEYS + PERCENTAGE_KEYS + STRUCTURED_KEYS).freeze
29
+
30
+ # Plural spellings accepted on input for parity with Magick.import.
31
+ ALIASES = {
32
+ users: :user,
33
+ groups: :group,
34
+ roles: :role,
35
+ tags: :tag,
36
+ ip_addresses: :ip_address
37
+ }.freeze
38
+
39
+ IP_KEYS = %i[ip_address excluded_ip_addresses].freeze
40
+
41
+ CUSTOM_ATTRIBUTE_OPERATORS = %i[equals eq not_equals ne in not_in greater_than gt less_than lt].freeze
42
+ COMPLEX_OPERATORS = %i[and or].freeze
43
+ COMPLEX_CONDITION_TYPES = %i[user group role custom_attribute].freeze
44
+
45
+ module_function
46
+
47
+ # Wire payload -> canonical internal targeting hash. Raises
48
+ # InvalidTargetingError on the first problem, before any state is
49
+ # touched, so callers get all-or-nothing semantics for free.
50
+ def normalize(payload)
51
+ raise InvalidTargetingError, "targeting must be a Hash, got #{payload.class}" unless payload.is_a?(Hash)
52
+
53
+ normalized = {}
54
+ payload.each do |raw_key, raw_value|
55
+ key = canonical_key(raw_key)
56
+ raise InvalidTargetingError, "conflicting targeting keys resolve to '#{key}'" if normalized.key?(key)
57
+ next if raw_value.nil? # nil = remove the rule, same as omitting the key
58
+
59
+ value = normalize_value(key, raw_value)
60
+ normalized[key] = value unless value.nil?
61
+ end
62
+ normalized
63
+ end
64
+
65
+ # Internal targeting hash -> wire payload. Never raises: the read path
66
+ # must serialize whatever historical state an adapter hands back.
67
+ def serialize(targeting)
68
+ return {} unless targeting.is_a?(Hash)
69
+
70
+ targeting.each_with_object({}) do |(key, value), wire|
71
+ key = key.to_sym
72
+ next if key == :variants # internal-only, managed via set_variants
73
+
74
+ wire[key.to_s] =
75
+ if ARRAY_KEYS.include?(key)
76
+ Array(value).map(&:to_s)
77
+ elsif PERCENTAGE_KEYS.include?(key)
78
+ value.to_f
79
+ elsif key == :date_range
80
+ serialize_date_range(value)
81
+ else
82
+ deep_stringify(value)
83
+ end
84
+ end
85
+ end
86
+
87
+ def canonical_key(raw_key)
88
+ key = raw_key.to_sym
89
+ key = ALIASES.fetch(key, key)
90
+ if key == :variants
91
+ raise InvalidTargetingError, 'variants are not part of the targeting payload (use set_variants)'
92
+ end
93
+ raise InvalidTargetingError, "unknown targeting key: '#{raw_key}'" unless CANONICAL_KEYS.include?(key)
94
+
95
+ key
96
+ end
97
+
98
+ def normalize_value(key, value)
99
+ if ARRAY_KEYS.include?(key)
100
+ normalize_array(key, value)
101
+ elsif PERCENTAGE_KEYS.include?(key)
102
+ normalize_percentage(key, value)
103
+ elsif key == :date_range
104
+ normalize_date_range(value)
105
+ elsif key == :custom_attributes
106
+ normalize_custom_attributes(value)
107
+ else # :complex_conditions
108
+ normalize_complex_conditions(value)
109
+ end
110
+ end
111
+
112
+ # Scalars are accepted as one-element lists; empty lists mean "no rule"
113
+ # and collapse to key removal, keeping {} the only spelling of
114
+ # "no targeting".
115
+ def normalize_array(key, value)
116
+ values = (value.is_a?(Array) ? value : [value]).compact.map { |v| v.to_s.strip }.reject(&:empty?).uniq
117
+ return nil if values.empty?
118
+
119
+ validate_ip_list!(key, values) if IP_KEYS.include?(key)
120
+ values
121
+ end
122
+
123
+ def validate_ip_list!(key, values)
124
+ values.each do |ip|
125
+ IPAddr.new(ip)
126
+ rescue ArgumentError # includes IPAddr::Error
127
+ raise InvalidTargetingError, "invalid IP address or CIDR range in '#{key}': '#{ip}'"
128
+ end
129
+ end
130
+
131
+ def normalize_percentage(key, value)
132
+ percentage = begin
133
+ Float(value)
134
+ rescue ArgumentError, TypeError
135
+ raise InvalidTargetingError, "'#{key}' must be a number, got #{value.inspect}"
136
+ end
137
+ unless percentage.positive? && percentage <= 100
138
+ raise InvalidTargetingError, "'#{key}' must be within (0, 100], got #{percentage}"
139
+ end
140
+
141
+ percentage
142
+ end
143
+
144
+ def normalize_date_range(value)
145
+ raise InvalidTargetingError, "'date_range' must be a Hash with start and end" unless value.is_a?(Hash)
146
+
147
+ start_value = value[:start] || value['start']
148
+ end_value = value[:end] || value['end']
149
+ raise InvalidTargetingError, "'date_range' requires both start and end" unless start_value && end_value
150
+
151
+ { start: parse_date_bound(start_value), end: parse_date_bound(end_value) }
152
+ end
153
+
154
+ def parse_date_bound(value)
155
+ return value unless value.is_a?(String)
156
+
157
+ Time.parse(value)
158
+ value
159
+ rescue ArgumentError
160
+ raise InvalidTargetingError, "'date_range' bound is not a parseable time: '#{value}'"
161
+ end
162
+
163
+ def normalize_custom_attributes(value)
164
+ raise InvalidTargetingError, "'custom_attributes' must be a Hash" unless value.is_a?(Hash)
165
+
166
+ value.each_with_object({}) do |(attribute, rule), result|
167
+ raise InvalidTargetingError, "rule for custom attribute '#{attribute}' must be a Hash" unless rule.is_a?(Hash)
168
+
169
+ values = Array(rule[:values] || rule['values']).compact.map(&:to_s).reject(&:empty?)
170
+ raise InvalidTargetingError, "custom attribute '#{attribute}' requires values" if values.empty?
171
+
172
+ operator = (rule[:operator] || rule['operator'] || :equals).to_sym
173
+ unless CUSTOM_ATTRIBUTE_OPERATORS.include?(operator)
174
+ raise InvalidTargetingError, "unknown operator '#{operator}' for custom attribute '#{attribute}'"
175
+ end
176
+
177
+ result[attribute.to_sym] = { values: values, operator: operator }
178
+ end
179
+ end
180
+
181
+ def normalize_complex_conditions(value)
182
+ raise InvalidTargetingError, "'complex_conditions' must be a Hash" unless value.is_a?(Hash)
183
+
184
+ operator = (value[:operator] || value['operator'] || :and).to_sym
185
+ unless COMPLEX_OPERATORS.include?(operator)
186
+ raise InvalidTargetingError, "'complex_conditions' operator must be and/or, got '#{operator}'"
187
+ end
188
+
189
+ conditions = value[:conditions] || value['conditions']
190
+ raise InvalidTargetingError, "'complex_conditions' requires a conditions Array" unless conditions.is_a?(Array)
191
+
192
+ { operator: operator, conditions: conditions.map { |c| normalize_complex_condition(c) } }
193
+ end
194
+
195
+ def normalize_complex_condition(condition)
196
+ raise InvalidTargetingError, 'each complex condition must be a Hash' unless condition.is_a?(Hash)
197
+
198
+ type = (condition[:type] || condition['type']).to_s.to_sym
199
+ unless COMPLEX_CONDITION_TYPES.include?(type)
200
+ raise InvalidTargetingError, "unknown complex condition type: '#{type}'"
201
+ end
202
+
203
+ params = condition[:params] || condition['params'] || {}
204
+ raise InvalidTargetingError, "params for complex condition '#{type}' must be a Hash" unless params.is_a?(Hash)
205
+
206
+ { type: type, params: params.transform_keys(&:to_sym) }
207
+ end
208
+
209
+ def serialize_date_range(value)
210
+ return deep_stringify(value) unless value.is_a?(Hash)
211
+
212
+ start_value = value[:start] || value['start']
213
+ end_value = value[:end] || value['end']
214
+ { 'start' => serialize_time(start_value), 'end' => serialize_time(end_value) }
215
+ end
216
+
217
+ def serialize_time(value)
218
+ return value if value.nil? || value.is_a?(String)
219
+ return value.iso8601 if value.respond_to?(:iso8601)
220
+
221
+ value.to_s
222
+ end
223
+
224
+ def deep_stringify(value)
225
+ case value
226
+ when Hash
227
+ value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
228
+ when Array
229
+ value.map { |v| deep_stringify(v) }
230
+ when Symbol
231
+ value.to_s
232
+ else
233
+ value
234
+ end
235
+ end
236
+ end
237
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.4.3'
4
+ VERSION = '1.6.0'
5
5
  end
@@ -1,15 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'time'
5
+
3
6
  module Magick
4
7
  class Versioning
8
+ DEFAULT_MAX_VERSIONS = 50
9
+
10
+ # Version history lives under a reserved pseudo-feature namespace so that
11
+ # feature reads (get_all_data) never drag snapshot blobs along, and so
12
+ # deleting a feature does not destroy its ActiveRecord archive row.
13
+ STORE_PREFIX = '__magick_versions:'
14
+
5
15
  class Version
6
- attr_reader :version, :feature_data, :timestamp, :created_by
16
+ attr_reader :version, :feature_data, :timestamp, :created_by, :action
7
17
 
8
- def initialize(version, feature_data, created_by: nil)
18
+ def initialize(version, feature_data, created_by: nil, action: nil, timestamp: nil)
9
19
  @version = version
10
20
  @feature_data = feature_data
11
- @timestamp = Time.now
21
+ @timestamp = timestamp || Time.now
12
22
  @created_by = created_by
23
+ @action = action
13
24
  end
14
25
 
15
26
  def to_h
@@ -17,66 +28,65 @@ module Magick
17
28
  version: version,
18
29
  feature_data: feature_data,
19
30
  timestamp: timestamp.iso8601,
20
- created_by: created_by
31
+ created_by: created_by,
32
+ action: action
21
33
  }
22
34
  end
23
35
  end
24
36
 
25
- def initialize(adapter_registry)
37
+ def initialize(adapter_registry, max_versions: DEFAULT_MAX_VERSIONS)
26
38
  @adapter_registry = adapter_registry
27
- @versions = {}
39
+ @max_versions = max_versions.to_i.positive? ? max_versions.to_i : DEFAULT_MAX_VERSIONS
40
+ # Process-local cache of the hot window, rehydrated lazily from the
41
+ # adapters so history survives restarts and is shared across containers.
42
+ @hot = {}
28
43
  @mutex = Mutex.new
29
44
  end
30
45
 
46
+ attr_reader :max_versions
47
+
48
+ # Explicit manual snapshot. Kept as public API from earlier releases;
49
+ # since 1.5.0 every Feature mutation also snapshots automatically.
31
50
  def save_version(feature_name, version: nil, created_by: nil)
32
51
  feature = Magick.features[feature_name.to_s] || Magick[feature_name]
33
- feature_name_str = feature_name.to_s
34
-
35
- # Compute version + append under the same mutex so two concurrent
36
- # save_version calls on the same feature can't both assign version N.
37
- version_data = @mutex.synchronize do
38
- list = (@versions[feature_name_str] ||= [])
39
- resolved_version = version || (list.empty? ? 1 : list.last.version + 1)
40
- entry = Version.new(resolved_version, feature.to_h, created_by: created_by)
41
- list << entry
42
- @adapter_registry.set(feature_name, "version_#{resolved_version}", entry.to_h)
43
- entry
44
- end
52
+ append(feature.name, snapshot_of(feature), version: version, created_by: created_by, action: 'manual')
53
+ end
45
54
 
46
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
47
- Magick::Rails::Events.version_saved(feature_name, version: version_data.version, created_by: created_by)
48
- end
55
+ # Called by Feature#record_change after every successful mutation.
56
+ # snapshot: allows callers (delete) to capture state before the mutation.
57
+ def record_change(feature, action: nil, created_by: nil, snapshot: nil)
58
+ data = snapshot ? deep_symbolize(JSON.parse(JSON.generate(snapshot))) : snapshot_of(feature)
59
+ append(feature.name, data, created_by: created_by, action: action)
60
+ end
61
+
62
+ # Hot window (last max_versions, memory/Redis) by default; all: true
63
+ # merges the unlimited ActiveRecord archive when one is configured.
64
+ def get_versions(feature_name, all: false)
65
+ name = feature_name.to_s
66
+ hot = @mutex.synchronize { hot_window(name).dup }
67
+ return hot unless all
49
68
 
50
- version_data
69
+ older = archive_versions(name).reject { |a| hot.any? { |h| h.version == a.version } }
70
+ (older + hot).sort_by(&:version)
51
71
  end
52
72
 
73
+ # Restore the feature to the snapshot stored in the given version, then
74
+ # record the rollback itself as a new version + audit entry (history only
75
+ # ever rolls forward). Restores state wholesale: value (including false/
76
+ # empty), status, group, the entire targeting hash, and dependencies.
53
77
  def rollback(feature_name, version)
54
- versions = get_versions(feature_name)
55
- target_version = versions.find { |v| v.version == version }
56
- return false unless target_version
78
+ entry = find_version(feature_name, version)
79
+ return false unless entry
57
80
 
58
81
  feature = Magick.features[feature_name.to_s] || Magick[feature_name]
59
- feature_data = target_version.feature_data
60
-
61
- # Restore feature state
62
- feature.set_value(feature_data[:value]) if feature_data[:value]
63
- feature.set_status(feature_data[:status]) if feature_data[:status]
64
-
65
- # Restore targeting
66
- feature_data[:targeting]&.each do |type, values|
67
- Array(values).each do |value|
68
- case type.to_sym
69
- when :user
70
- feature.enable_for_user(value)
71
- when :group
72
- feature.enable_for_group(value)
73
- when :role
74
- feature.enable_for_role(value)
75
- end
76
- end
82
+ Magick.suppress_change_recording do
83
+ feature.restore_snapshot!(entry.feature_data)
77
84
  end
78
85
 
79
- # Rails 8+ event
86
+ actor = Magick.current_actor
87
+ Magick.audit_log&.log(feature_name, 'rollback', user_id: actor, changes: { rolled_back_to: version })
88
+ record_change(feature, action: 'rollback', created_by: actor)
89
+
80
90
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
81
91
  Magick::Rails::Events.rollback(feature_name, version: version)
82
92
  end
@@ -84,17 +94,161 @@ module Magick
84
94
  true
85
95
  end
86
96
 
87
- def get_versions(feature_name)
88
- @mutex.synchronize { (@versions[feature_name.to_s] || []).dup }
97
+ private
98
+
99
+ # Deep copy via JSON so stored snapshots never alias the feature's live
100
+ # targeting hash, and in-process entries match adapter-rehydrated ones.
101
+ def snapshot_of(feature)
102
+ deep_symbolize(JSON.parse(JSON.generate(feature.to_h)))
89
103
  end
90
104
 
91
- private
105
+ def append(name, snapshot, version: nil, created_by: nil, action: nil)
106
+ entry = @mutex.synchronize do
107
+ window = hot_window(name)
108
+ resolved = version || (window.last ? window.last.version + 1 : 1)
109
+ record = Version.new(resolved, snapshot, created_by: created_by, action: action)
110
+ window << record
111
+ window.shift while window.size > @max_versions
112
+ persist_hot_window(name, window)
113
+ persist_archive(name, record)
114
+ record
115
+ end
116
+
117
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
118
+ Magick::Rails::Events.version_saved(name, version: entry.version, created_by: created_by)
119
+ end
120
+
121
+ entry
122
+ end
123
+
124
+ # Callers must hold @mutex.
125
+ def hot_window(name)
126
+ @hot[name] ||= load_hot_window(name)
127
+ end
128
+
129
+ def load_hot_window(name)
130
+ raw = read_hot_list(name)
131
+ raw = read_archive_tail(name) if raw.nil? || raw.empty?
132
+ Array(raw).filter_map { |h| rehydrate(h) }.sort_by(&:version).last(@max_versions)
133
+ end
134
+
135
+ def read_hot_list(name)
136
+ hot_adapters.each do |adapter|
137
+ list = safely { adapter.get(store_name(name), 'versions') }
138
+ list = parse_json(list) if list.is_a?(String)
139
+ return list if list.is_a?(Array) && !list.empty?
140
+ end
141
+ nil
142
+ end
143
+
144
+ # Seed the hot window from the archive when memory/Redis are empty
145
+ # (fresh boot, Redis flush) so version numbering continues, not restarts.
146
+ def read_archive_tail(name)
147
+ adapter = archive_adapter
148
+ return nil unless adapter
149
+
150
+ data = safely { adapter.get_all_data(store_name(name)) }
151
+ return nil unless data.is_a?(Hash)
92
152
 
93
- def next_version(feature_name)
94
- @mutex.synchronize do
95
- list = @versions[feature_name.to_s] || []
96
- list.empty? ? 1 : list.last.version + 1
153
+ data.filter_map { |key, value| value if key.to_s.start_with?('version_') }
154
+ end
155
+
156
+ def archive_versions(name)
157
+ adapter = archive_adapter
158
+ return [] unless adapter
159
+
160
+ data = safely { adapter.get_all_data(store_name(name)) }
161
+ return [] unless data.is_a?(Hash)
162
+
163
+ data.filter_map { |key, value| rehydrate(value) if key.to_s.start_with?('version_') }.sort_by(&:version)
164
+ end
165
+
166
+ def persist_hot_window(name, window)
167
+ payload = window.map(&:to_h)
168
+ hot_adapters.each do |adapter|
169
+ safely { adapter.set(store_name(name), 'versions', payload) }
97
170
  end
98
171
  end
172
+
173
+ def persist_archive(name, entry)
174
+ adapter = archive_adapter
175
+ return unless adapter
176
+
177
+ safely { adapter.set(store_name(name), "version_#{entry.version}", entry.to_h) }
178
+ end
179
+
180
+ def find_version(feature_name, version)
181
+ name = feature_name.to_s
182
+ hot = @mutex.synchronize { hot_window(name).dup }
183
+ found = hot.find { |v| v.version == version }
184
+ return found if found
185
+
186
+ adapter = archive_adapter
187
+ return nil unless adapter
188
+
189
+ raw = safely { adapter.get(store_name(name), "version_#{version}") }
190
+ raw ? rehydrate(raw) : nil
191
+ end
192
+
193
+ # Hot window lives in memory + Redis (capped); the ActiveRecord adapter
194
+ # keeps the unlimited archive, one version_<n> key per entry.
195
+ def hot_adapters
196
+ registry = @adapter_registry
197
+ if registry.respond_to?(:memory_adapter)
198
+ [registry.memory_adapter, registry.redis_adapter].compact
199
+ else
200
+ [registry]
201
+ end
202
+ end
203
+
204
+ def archive_adapter
205
+ registry = @adapter_registry
206
+ registry.respond_to?(:active_record_adapter) ? registry.active_record_adapter : nil
207
+ end
208
+
209
+ def store_name(name)
210
+ "#{STORE_PREFIX}#{name}"
211
+ end
212
+
213
+ def rehydrate(raw)
214
+ raw = parse_json(raw) if raw.is_a?(String)
215
+ return nil unless raw.is_a?(Hash)
216
+
217
+ data = deep_symbolize(raw)
218
+ timestamp = data[:timestamp]
219
+ timestamp = safely { Time.parse(timestamp) } if timestamp.is_a?(String)
220
+ Version.new(
221
+ data[:version],
222
+ data[:feature_data] || {},
223
+ created_by: data[:created_by],
224
+ action: data[:action],
225
+ timestamp: timestamp
226
+ )
227
+ end
228
+
229
+ def parse_json(str)
230
+ JSON.parse(str)
231
+ rescue JSON::ParserError
232
+ nil
233
+ end
234
+
235
+ def deep_symbolize(obj)
236
+ case obj
237
+ when Hash
238
+ obj.each_with_object({}) { |(k, v), out| out[k.to_sym] = deep_symbolize(v) }
239
+ when Array
240
+ obj.map { |v| deep_symbolize(v) }
241
+ else
242
+ obj
243
+ end
244
+ end
245
+
246
+ # Version bookkeeping is best-effort: a flaky adapter must never turn a
247
+ # feature toggle into an exception.
248
+ def safely
249
+ yield
250
+ rescue StandardError
251
+ nil
252
+ end
99
253
  end
100
254
  end
data/lib/magick.rb CHANGED
@@ -23,6 +23,7 @@ require_relative 'magick/targeting/ip_address'
23
23
  require_relative 'magick/targeting/custom_attribute'
24
24
  require_relative 'magick/targeting/complex'
25
25
  require_relative 'magick/errors'
26
+ require_relative 'magick/targeting_payload'
26
27
 
27
28
  require_relative 'magick/log_safe'
28
29
  require_relative 'magick/audit_log'
@@ -216,6 +217,64 @@ module Magick
216
217
  @versioning ||= Versioning.new(adapter_registry || default_adapter_registry)
217
218
  end
218
219
 
220
+ # When false, Feature#record_change skips version snapshots (audit log
221
+ # entries are still written). Set via `versioning enabled: false` in the
222
+ # configuration DSL.
223
+ attr_writer :versioning_enabled
224
+
225
+ def versioning_enabled?
226
+ @versioning_enabled.nil? || @versioning_enabled != false
227
+ end
228
+
229
+ # Attribute all changes made inside the block to the given actor. Audit
230
+ # entries pick it up as user_id and versions as created_by, unless the
231
+ # call site passes an explicit user_id:.
232
+ #
233
+ # Magick.with_actor(current_user.id) { Magick[:checkout].enable }
234
+ def with_actor(actor)
235
+ previous = Thread.current[:magick_actor]
236
+ Thread.current[:magick_actor] = actor
237
+ yield
238
+ ensure
239
+ Thread.current[:magick_actor] = previous
240
+ end
241
+
242
+ def current_actor
243
+ Thread.current[:magick_actor]
244
+ end
245
+
246
+ # Suppress audit/version recording while declarative feature definitions
247
+ # are (re)applied. Process boot replays config/features.rb in every
248
+ # container; recording those replays would flood history with identical
249
+ # snapshots. The Rails railtie wraps the features file load in this;
250
+ # non-Rails apps should do the same around their definition file.
251
+ def definition_mode
252
+ previous = Thread.current[:magick_definition_mode]
253
+ Thread.current[:magick_definition_mode] = true
254
+ yield
255
+ ensure
256
+ Thread.current[:magick_definition_mode] = previous
257
+ end
258
+
259
+ def definition_mode?
260
+ Thread.current[:magick_definition_mode] == true
261
+ end
262
+
263
+ # Reentrancy guard for Feature#record_change: the outermost public
264
+ # mutator records once; nested mutator calls (enable -> set_value) run
265
+ # silently so one logical operation never produces multiple entries.
266
+ def suppress_change_recording
267
+ previous = Thread.current[:magick_change_recording]
268
+ Thread.current[:magick_change_recording] = true
269
+ yield
270
+ ensure
271
+ Thread.current[:magick_change_recording] = previous
272
+ end
273
+
274
+ def change_recording_suppressed?
275
+ Thread.current[:magick_change_recording] == true || definition_mode?
276
+ end
277
+
219
278
  # Manually enable Redis tracking for performance metrics
220
279
  # Useful if Redis adapter becomes available after initial configuration
221
280
  def enable_redis_tracking(enable: true)
@@ -280,6 +339,9 @@ module Magick
280
339
  @adapter_registry = nil
281
340
  @default_adapter = nil
282
341
  @default_adapter_registry = nil
342
+ @versioning = nil
343
+ @audit_log = nil
344
+ @versioning_enabled = nil
283
345
  @performance_metrics&.clear!
284
346
  end
285
347
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.3
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov
@@ -145,6 +145,7 @@ files:
145
145
  - lib/magick/targeting/request_percentage.rb
146
146
  - lib/magick/targeting/role.rb
147
147
  - lib/magick/targeting/user.rb
148
+ - lib/magick/targeting_payload.rb
148
149
  - lib/magick/testing_helpers.rb
149
150
  - lib/magick/version.rb
150
151
  - lib/magick/versioning.rb