magick-feature-flags 1.4.2 → 1.5.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.
@@ -233,187 +233,246 @@ module Magick
233
233
  end
234
234
 
235
235
  def enable_for_user(user_id)
236
- enable_targeting(:user, user_id)
236
+ record_change('enable_for_user', targeting_change(:user, added: user_id)) do
237
+ enable_targeting(:user, user_id)
238
+ end
237
239
  true
238
240
  end
239
241
 
240
242
  def disable_for_user(user_id)
241
- disable_targeting(:user, user_id)
243
+ record_change('disable_for_user', targeting_change(:user, removed: user_id)) do
244
+ disable_targeting(:user, user_id)
245
+ end
242
246
  true
243
247
  end
244
248
 
245
249
  def enable_for_group(group_name)
246
- enable_targeting(:group, group_name)
250
+ record_change('enable_for_group', targeting_change(:group, added: group_name)) do
251
+ enable_targeting(:group, group_name)
252
+ end
247
253
  true
248
254
  end
249
255
 
250
256
  def disable_for_group(group_name)
251
- disable_targeting(:group, group_name)
257
+ record_change('disable_for_group', targeting_change(:group, removed: group_name)) do
258
+ disable_targeting(:group, group_name)
259
+ end
252
260
  true
253
261
  end
254
262
 
255
263
  def enable_for_role(role_name)
256
- enable_targeting(:role, role_name)
264
+ record_change('enable_for_role', targeting_change(:role, added: role_name)) do
265
+ enable_targeting(:role, role_name)
266
+ end
257
267
  true
258
268
  end
259
269
 
260
270
  def disable_for_role(role_name)
261
- disable_targeting(:role, role_name)
271
+ record_change('disable_for_role', targeting_change(:role, removed: role_name)) do
272
+ disable_targeting(:role, role_name)
273
+ end
262
274
  true
263
275
  end
264
276
 
265
277
  def enable_for_tag(tag_name)
266
- enable_targeting(:tag, tag_name)
278
+ record_change('enable_for_tag', targeting_change(:tag, added: tag_name)) do
279
+ enable_targeting(:tag, tag_name)
280
+ end
267
281
  true
268
282
  end
269
283
 
270
284
  def disable_for_tag(tag_name)
271
- disable_targeting(:tag, tag_name)
285
+ record_change('disable_for_tag', targeting_change(:tag, removed: tag_name)) do
286
+ disable_targeting(:tag, tag_name)
287
+ end
272
288
  true
273
289
  end
274
290
 
275
291
  # --- Exclusion methods ---
276
292
 
277
293
  def exclude_user(user_id)
278
- enable_targeting(:excluded_users, user_id)
294
+ record_change('exclude_user', targeting_change(:excluded_users, added: user_id)) do
295
+ enable_targeting(:excluded_users, user_id)
296
+ end
279
297
  true
280
298
  end
281
299
 
282
300
  def remove_user_exclusion(user_id)
283
- disable_targeting(:excluded_users, user_id)
301
+ record_change('remove_user_exclusion', targeting_change(:excluded_users, removed: user_id)) do
302
+ disable_targeting(:excluded_users, user_id)
303
+ end
284
304
  true
285
305
  end
286
306
 
287
307
  def exclude_tag(tag_name)
288
- enable_targeting(:excluded_tags, tag_name)
308
+ record_change('exclude_tag', targeting_change(:excluded_tags, added: tag_name)) do
309
+ enable_targeting(:excluded_tags, tag_name)
310
+ end
289
311
  true
290
312
  end
291
313
 
292
314
  def remove_tag_exclusion(tag_name)
293
- disable_targeting(:excluded_tags, tag_name)
315
+ record_change('remove_tag_exclusion', targeting_change(:excluded_tags, removed: tag_name)) do
316
+ disable_targeting(:excluded_tags, tag_name)
317
+ end
294
318
  true
295
319
  end
296
320
 
297
321
  def exclude_group(group_name)
298
- enable_targeting(:excluded_groups, group_name)
322
+ record_change('exclude_group', targeting_change(:excluded_groups, added: group_name)) do
323
+ enable_targeting(:excluded_groups, group_name)
324
+ end
299
325
  true
300
326
  end
301
327
 
302
328
  def remove_group_exclusion(group_name)
303
- disable_targeting(:excluded_groups, group_name)
329
+ record_change('remove_group_exclusion', targeting_change(:excluded_groups, removed: group_name)) do
330
+ disable_targeting(:excluded_groups, group_name)
331
+ end
304
332
  true
305
333
  end
306
334
 
307
335
  def exclude_role(role_name)
308
- enable_targeting(:excluded_roles, role_name)
336
+ record_change('exclude_role', targeting_change(:excluded_roles, added: role_name)) do
337
+ enable_targeting(:excluded_roles, role_name)
338
+ end
309
339
  true
310
340
  end
311
341
 
312
342
  def remove_role_exclusion(role_name)
313
- disable_targeting(:excluded_roles, role_name)
343
+ record_change('remove_role_exclusion', targeting_change(:excluded_roles, removed: role_name)) do
344
+ disable_targeting(:excluded_roles, role_name)
345
+ end
314
346
  true
315
347
  end
316
348
 
317
349
  def exclude_ip_addresses(ip_addresses)
318
- # excluded_ip_addresses is stored as a flat Array of strings; bypass the
319
- # generic enable_targeting path whose "array type" branch stringifies the
320
- # incoming array into a single element.
321
- @targeting[:excluded_ip_addresses] ||= []
322
- Array(ip_addresses).each do |ip|
323
- str = ip.to_s
324
- @targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
350
+ ips = Array(ip_addresses).map(&:to_s)
351
+ record_change('exclude_ip_addresses', targeting_change(:excluded_ip_addresses, added: ips)) do
352
+ # excluded_ip_addresses is stored as a flat Array of strings; bypass the
353
+ # generic enable_targeting path whose "array type" branch stringifies the
354
+ # incoming array into a single element.
355
+ @targeting[:excluded_ip_addresses] ||= []
356
+ ips.each do |str|
357
+ @targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
358
+ end
359
+ save_targeting
325
360
  end
326
- save_targeting
327
361
  true
328
362
  end
329
363
 
330
364
  def remove_ip_exclusion
331
- disable_targeting(:excluded_ip_addresses)
365
+ record_change('remove_ip_exclusion', targeting_change(:excluded_ip_addresses)) do
366
+ disable_targeting(:excluded_ip_addresses)
367
+ end
332
368
  true
333
369
  end
334
370
 
335
371
  def enable_percentage_of_users(percentage)
336
- @targeting[:percentage_users] = percentage.to_f
337
- save_targeting
372
+ record_change('enable_percentage_of_users', targeting_change(:percentage_users, added: percentage.to_f)) do
373
+ @targeting[:percentage_users] = percentage.to_f
374
+ save_targeting
338
375
 
339
- # Update registered feature instance if it exists
340
- Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
376
+ # Update registered feature instance if it exists
377
+ Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
341
378
 
342
- # Rails 8+ event
343
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
344
- Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
379
+ # Rails 8+ event
380
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
381
+ Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
382
+ end
345
383
  end
346
384
 
347
385
  true
348
386
  end
349
387
 
350
388
  def disable_percentage_of_users
351
- disable_targeting(:percentage_users)
389
+ record_change('disable_percentage_of_users', targeting_change(:percentage_users)) do
390
+ disable_targeting(:percentage_users)
391
+ end
352
392
  true
353
393
  end
354
394
 
355
395
  def enable_percentage_of_requests(percentage)
356
- @targeting[:percentage_requests] = percentage.to_f
357
- save_targeting
396
+ record_change('enable_percentage_of_requests', targeting_change(:percentage_requests, added: percentage.to_f)) do
397
+ @targeting[:percentage_requests] = percentage.to_f
398
+ save_targeting
358
399
 
359
- # Update registered feature instance if it exists
360
- Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
400
+ # Update registered feature instance if it exists
401
+ Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
361
402
 
362
- # Rails 8+ event
363
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
364
- Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
403
+ # Rails 8+ event
404
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
405
+ Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
406
+ end
365
407
  end
366
408
 
367
409
  true
368
410
  end
369
411
 
370
412
  def disable_percentage_of_requests
371
- disable_targeting(:percentage_requests)
413
+ record_change('disable_percentage_of_requests', targeting_change(:percentage_requests)) do
414
+ disable_targeting(:percentage_requests)
415
+ end
372
416
  true
373
417
  end
374
418
 
375
419
  def enable_for_date_range(start_date, end_date)
376
- enable_targeting(:date_range, { start: start_date, end: end_date })
420
+ record_change('enable_for_date_range',
421
+ targeting_change(:date_range, added: { start: start_date, end: end_date })) do
422
+ enable_targeting(:date_range, { start: start_date, end: end_date })
423
+ end
377
424
  true
378
425
  end
379
426
 
380
427
  def disable_date_range
381
- disable_targeting(:date_range)
428
+ record_change('disable_date_range', targeting_change(:date_range)) do
429
+ disable_targeting(:date_range)
430
+ end
382
431
  true
383
432
  end
384
433
 
385
434
  def enable_for_ip_addresses(ip_addresses)
386
- # ip_address is stored as a flat Array of strings; bypass the generic
387
- # enable_targeting path whose "array type" branch stringifies the
388
- # incoming array into a single '["x.y.z"]' entry.
389
- @targeting[:ip_address] ||= []
390
- Array(ip_addresses).each do |ip|
391
- str = ip.to_s
392
- @targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
435
+ ips = Array(ip_addresses).map(&:to_s)
436
+ record_change('enable_for_ip_addresses', targeting_change(:ip_address, added: ips)) do
437
+ # ip_address is stored as a flat Array of strings; bypass the generic
438
+ # enable_targeting path whose "array type" branch stringifies the
439
+ # incoming array into a single '["x.y.z"]' entry.
440
+ @targeting[:ip_address] ||= []
441
+ ips.each do |str|
442
+ @targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
443
+ end
444
+ save_targeting
393
445
  end
394
- save_targeting
395
446
  true
396
447
  end
397
448
 
398
449
  def disable_ip_addresses
399
- disable_targeting(:ip_address)
450
+ record_change('disable_ip_addresses', targeting_change(:ip_address)) do
451
+ disable_targeting(:ip_address)
452
+ end
400
453
  true
401
454
  end
402
455
 
403
456
  def enable_for_custom_attribute(attribute_name, values, operator: :equals)
404
- custom_attrs = targeting[:custom_attributes] || {}
405
- custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
406
- enable_targeting(:custom_attributes, custom_attrs)
457
+ change = targeting_change(:custom_attributes,
458
+ added: { attribute: attribute_name, values: Array(values), operator: operator })
459
+ record_change('enable_for_custom_attribute', change) do
460
+ custom_attrs = targeting[:custom_attributes] || {}
461
+ custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
462
+ enable_targeting(:custom_attributes, custom_attrs)
463
+ end
407
464
  true
408
465
  end
409
466
 
410
467
  def disable_custom_attribute(attribute_name)
411
- custom_attrs = targeting[:custom_attributes] || {}
412
- custom_attrs.delete(attribute_name.to_sym)
413
- if custom_attrs.empty?
414
- disable_targeting(:custom_attributes)
415
- else
416
- enable_targeting(:custom_attributes, custom_attrs)
468
+ record_change('disable_custom_attribute', targeting_change(:custom_attributes, removed: attribute_name)) do
469
+ custom_attrs = targeting[:custom_attributes] || {}
470
+ custom_attrs.delete(attribute_name.to_sym)
471
+ if custom_attrs.empty?
472
+ disable_targeting(:custom_attributes)
473
+ else
474
+ enable_targeting(:custom_attributes, custom_attrs)
475
+ end
417
476
  end
418
477
  true
419
478
  end
@@ -422,34 +481,42 @@ module Magick
422
481
  variants_array = Array(variants).map do |v|
423
482
  v.is_a?(FeatureVariant) ? v : FeatureVariant.new(v[:name], v[:value], weight: v[:weight] || 0)
424
483
  end
425
- enable_targeting(:variants, variants_array.map(&:to_h))
484
+ payload = variants_array.map(&:to_h)
426
485
 
427
- # Rails 8+ event
428
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
429
- Magick::Rails::Events.variant_set(name, variants: variants_array)
486
+ record_change('set_variants', { variants: payload }) do
487
+ enable_targeting(:variants, payload)
488
+
489
+ # Rails 8+ event
490
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
491
+ Magick::Rails::Events.variant_set(name, variants: variants_array)
492
+ end
430
493
  end
431
494
 
432
495
  true
433
496
  end
434
497
 
435
498
  def add_dependency(dependency_name)
436
- @dependencies ||= []
437
- @dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s)
499
+ record_change('add_dependency', { dependency: { added: dependency_name.to_s } }) do
500
+ @dependencies ||= []
501
+ @dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s)
438
502
 
439
- # Rails 8+ event
440
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
441
- Magick::Rails::Events.dependency_added(name, dependency_name)
503
+ # Rails 8+ event
504
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
505
+ Magick::Rails::Events.dependency_added(name, dependency_name)
506
+ end
442
507
  end
443
508
 
444
509
  true
445
510
  end
446
511
 
447
512
  def remove_dependency(dependency_name)
448
- @dependencies&.delete(dependency_name.to_s)
513
+ record_change('remove_dependency', { dependency: { removed: dependency_name.to_s } }) do
514
+ @dependencies&.delete(dependency_name.to_s)
449
515
 
450
- # Rails 8+ event
451
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
452
- Magick::Rails::Events.dependency_removed(name, dependency_name)
516
+ # Rails 8+ event
517
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
518
+ Magick::Rails::Events.dependency_removed(name, dependency_name)
519
+ end
453
520
  end
454
521
 
455
522
  true
@@ -501,90 +568,93 @@ module Magick
501
568
  old_value = @stored_value
502
569
  validate_value!(value)
503
570
 
504
- # Bulk write all metadata in a single adapter call instead of 7 separate calls
505
- data = { 'value' => value, 'type' => type, 'status' => status, 'default_value' => default_value }
506
- data['description'] = description if description
507
- data['display_name'] = display_name if display_name
508
- data['group'] = group if group
509
- adapter_registry.set_all_data(name, data)
510
-
511
- @stored_value = value
512
- @stored_value_initialized = true
571
+ changes = { value: { from: old_value, to: value } }
513
572
 
514
- # Update registered feature instance if it exists
515
- if Magick.features.key?(name)
516
- registered = Magick.features[name]
517
- registered.instance_variable_set(:@stored_value, value)
518
- registered.instance_variable_set(:@stored_value_initialized, true)
519
- registered.instance_variable_set(:@targeting, @targeting.dup) if @targeting
520
- end
573
+ record_change('set_value', changes, user_id: user_id) do
574
+ # Bulk write all metadata in a single adapter call instead of 7 separate calls
575
+ data = { 'value' => value, 'type' => type, 'status' => status, 'default_value' => default_value }
576
+ data['description'] = description if description
577
+ data['display_name'] = display_name if display_name
578
+ data['group'] = group if group
579
+ adapter_registry.set_all_data(name, data)
521
580
 
522
- changes = { value: { from: old_value, to: value } }
581
+ @stored_value = value
582
+ @stored_value_initialized = true
523
583
 
524
- Magick.audit_log&.log(
525
- name,
526
- 'set_value',
527
- user_id: user_id,
528
- changes: changes
529
- )
584
+ # Update registered feature instance if it exists
585
+ if Magick.features.key?(name)
586
+ registered = Magick.features[name]
587
+ registered.instance_variable_set(:@stored_value, value)
588
+ registered.instance_variable_set(:@stored_value_initialized, true)
589
+ registered.instance_variable_set(:@targeting, @targeting.dup) if @targeting
590
+ end
530
591
 
531
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
532
- Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
533
- Magick::Rails::Events.audit_logged(name, action: 'set_value', user_id: user_id, changes: changes)
592
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
593
+ Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
594
+ end
534
595
  end
535
596
 
536
597
  true
537
598
  end
538
599
 
539
600
  def enable(user_id: nil)
540
- # Clear all targeting to enable globally
541
- @targeting = {}
542
- save_targeting
601
+ changes = { value: { from: @stored_value, to: true }, targeting: { cleared: true } }
543
602
 
544
- case type
545
- when :boolean
546
- set_value(true, user_id: user_id)
547
- when :string
548
- raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.'
549
- when :number
550
- raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.'
551
- else
552
- raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
553
- end
603
+ record_change('enable', changes, user_id: user_id) do
604
+ # Clear all targeting to enable globally
605
+ @targeting = {}
606
+ save_targeting
607
+
608
+ case type
609
+ when :boolean
610
+ set_value(true, user_id: user_id)
611
+ when :string
612
+ raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.'
613
+ when :number
614
+ raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.'
615
+ else
616
+ raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
617
+ end
554
618
 
555
- # Rails 8+ event
556
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
557
- Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
619
+ # Rails 8+ event
620
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
621
+ Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
622
+ end
558
623
  end
559
624
 
560
625
  true
561
626
  end
562
627
 
563
628
  def disable(user_id: nil)
564
- # Clear all targeting to disable globally
565
- @targeting = {}
566
- save_targeting
629
+ disabled_value = { boolean: false, string: '', number: 0 }[type]
630
+ changes = { value: { from: @stored_value, to: disabled_value }, targeting: { cleared: true } }
567
631
 
568
- case type
569
- when :boolean
570
- set_value(false, user_id: user_id)
571
- when :string
572
- set_value('', user_id: user_id)
573
- when :number
574
- set_value(0, user_id: user_id)
575
- else
576
- raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
577
- end
632
+ record_change('disable', changes, user_id: user_id) do
633
+ # Clear all targeting to disable globally
634
+ @targeting = {}
635
+ save_targeting
636
+
637
+ case type
638
+ when :boolean
639
+ set_value(false, user_id: user_id)
640
+ when :string
641
+ set_value('', user_id: user_id)
642
+ when :number
643
+ set_value(0, user_id: user_id)
644
+ else
645
+ raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
646
+ end
578
647
 
579
- # Ensure registered feature instance also has targeting cleared
580
- if Magick.features.key?(name)
581
- registered = Magick.features[name]
582
- registered.instance_variable_set(:@targeting, {})
583
- end
648
+ # Ensure registered feature instance also has targeting cleared
649
+ if Magick.features.key?(name)
650
+ registered = Magick.features[name]
651
+ registered.instance_variable_set(:@targeting, {})
652
+ end
584
653
 
585
- # Rails 8+ event
586
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
587
- Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
654
+ # Rails 8+ event
655
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
656
+ Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
657
+ end
588
658
  end
589
659
 
590
660
  true
@@ -593,37 +663,53 @@ module Magick
593
663
  def set_status(new_status)
594
664
  raise InvalidFeatureValueError, "Invalid status: #{new_status}" unless VALID_STATUSES.include?(new_status.to_sym)
595
665
 
596
- @status = new_status.to_sym
597
- adapter_registry.set(name, 'status', status)
666
+ record_change('set_status', { status: { from: @status, to: new_status.to_sym } }) do
667
+ @status = new_status.to_sym
668
+ adapter_registry.set(name, 'status', status)
669
+ end
598
670
  true
599
671
  end
600
672
 
601
673
  def set_group(group_name)
602
- if group_name.nil? || group_name.to_s.strip.empty?
603
- @group = nil
604
- # Clear group from adapter by setting to empty string (adapters handle this)
605
- adapter_registry.set(name, 'group', nil)
606
- else
607
- @group = group_name.to_s.strip
674
+ new_group = group_name.nil? || group_name.to_s.strip.empty? ? nil : group_name.to_s.strip
675
+
676
+ record_change('set_group', { group: { from: @group, to: new_group } }) do
677
+ @group = new_group
678
+ # Clear group from adapter by setting to nil (adapters handle this)
608
679
  adapter_registry.set(name, 'group', @group)
609
- end
610
680
 
611
- # Update registered feature instance if it exists
612
- Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name)
681
+ # Update registered feature instance if it exists
682
+ Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name)
683
+ end
613
684
 
614
685
  true
615
686
  end
616
687
 
617
688
  def delete
618
- adapter_registry.delete(name)
619
- @stored_value = nil
620
- @stored_value_initialized = false # Reset initialization flag so get_value returns default_value
621
- @targeting = {}
622
- # Also remove from Magick.features if registered
623
- Magick.features.delete(name.to_s)
689
+ # Snapshot before the wipe so the recorded version captures the state
690
+ # this feature had when it was deleted, not the emptied one.
691
+ snapshot = to_h
692
+
693
+ record_change('delete', { deleted: true }, snapshot: snapshot) do
694
+ adapter_registry.delete(name)
695
+ @stored_value = nil
696
+ @stored_value_initialized = false # Reset initialization flag so get_value returns default_value
697
+ @targeting = {}
698
+ # Also remove from Magick.features if registered
699
+ Magick.features.delete(name.to_s)
700
+ end
624
701
  true
625
702
  end
626
703
 
704
+ # Reload feature state from the shared, authoritative backend (ActiveRecord/
705
+ # Redis), bypassing this process's local in-process memory cache. Used by the
706
+ # Admin UI so a toggle performed on another process/container is reflected
707
+ # immediately, without waiting for Pub/Sub cache invalidation to arrive.
708
+ def reload_from_source!
709
+ adapter_registry.authoritative_get_all_data(name) if adapter_registry.respond_to?(:authoritative_get_all_data)
710
+ reload
711
+ end
712
+
627
713
  # Reload feature state from adapter (useful when feature is changed externally)
628
714
  def reload
629
715
  load_from_adapter
@@ -677,6 +763,69 @@ module Magick
677
763
  end
678
764
 
679
765
  def save_targeting
766
+ # Records only when called directly (e.g. Admin UI clearing variants);
767
+ # when reached through a wrapping mutator the guard is active and this
768
+ # just persists.
769
+ record_change('update_targeting', { targeting: targeting.dup }) do
770
+ persist_targeting
771
+ end
772
+ end
773
+
774
+ # Restore full feature state from a version snapshot (used by
775
+ # Versioning#rollback). Replaces value (including false/empty), status,
776
+ # group, the entire targeting hash and dependencies wholesale.
777
+ def restore_snapshot!(data)
778
+ set_status(data[:status]) if data[:status]
779
+ set_group(data[:group]) if data.key?(:group)
780
+
781
+ value = data[:value]
782
+ set_value(cast_value(value)) unless value.nil?
783
+
784
+ @targeting = normalize_targeting(data[:targeting])
785
+ save_targeting
786
+
787
+ @dependencies = Array(data[:dependencies]).map(&:to_s)
788
+ true
789
+ end
790
+
791
+ private
792
+
793
+ attr_reader :targeting
794
+
795
+ # Single choke point for change tracking: wraps a public mutator's body,
796
+ # then writes one audit entry and one version snapshot for the operation.
797
+ # A thread-local guard makes nested mutator calls (enable -> set_value)
798
+ # silent, so one logical operation records exactly once, under its real
799
+ # action name. No-op while definitions are being (re)applied at boot.
800
+ def record_change(action, changes = {}, user_id: nil, snapshot: nil)
801
+ if Magick.change_recording_suppressed?
802
+ return block_given? ? yield : true
803
+ end
804
+
805
+ result = block_given? ? Magick.suppress_change_recording { yield } : true
806
+
807
+ actor = user_id || Magick.current_actor
808
+ Magick.audit_log&.log(name, action, user_id: actor, changes: changes)
809
+
810
+ if Magick.versioning_enabled?
811
+ begin
812
+ Magick.versioning&.record_change(self, action: action, created_by: actor, snapshot: snapshot)
813
+ rescue StandardError => e
814
+ warn "Magick: Failed to record version for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
815
+ end
816
+ end
817
+
818
+ result
819
+ end
820
+
821
+ def targeting_change(type, added: nil, removed: nil)
822
+ change = { type: type }
823
+ change[:added] = added unless added.nil?
824
+ change[:removed] = removed unless removed.nil?
825
+ { targeting: change }
826
+ end
827
+
828
+ def persist_targeting
680
829
  # Save targeting to adapter (this updates memory synchronously, then Redis/AR)
681
830
  # The set method already publishes cache invalidation to other processes via Pub/Sub
682
831
  adapter_registry.set(name, 'targeting', targeting)
@@ -697,10 +846,6 @@ module Magick
697
846
  # 3. The set method handles both sync and async Redis updates correctly
698
847
  end
699
848
 
700
- private
701
-
702
- attr_reader :targeting
703
-
704
849
  def stored_value
705
850
  return @stored_value if @stored_value_initialized
706
851
 
@@ -739,14 +884,7 @@ module Magick
739
884
  group_value = all_data['group']
740
885
  @group = group_value if group_value
741
886
 
742
- targeting_value = all_data['targeting']
743
- if targeting_value.is_a?(Hash)
744
- @targeting = targeting_value.transform_keys(&:to_sym)
745
- @targeting[:percentage_users] = @targeting[:percentage_users].to_f if @targeting[:percentage_users]
746
- @targeting[:percentage_requests] = @targeting[:percentage_requests].to_f if @targeting[:percentage_requests]
747
- else
748
- @targeting = {}
749
- end
887
+ @targeting = normalize_targeting(all_data['targeting'])
750
888
 
751
889
  # Track what was loaded so save_metadata_if_new can skip unnecessary writes
752
890
  @_loaded_description = all_data['description']
@@ -754,6 +892,15 @@ module Magick
754
892
  @_loaded_group = all_data['group']
755
893
  end
756
894
 
895
+ def normalize_targeting(raw)
896
+ return {} unless raw.is_a?(Hash)
897
+
898
+ normalized = raw.transform_keys(&:to_sym)
899
+ normalized[:percentage_users] = normalized[:percentage_users].to_f if normalized[:percentage_users]
900
+ normalized[:percentage_requests] = normalized[:percentage_requests].to_f if normalized[:percentage_requests]
901
+ normalized
902
+ end
903
+
757
904
  def save_metadata_if_new
758
905
  # Only write to adapter if the DSL value differs from what was loaded
759
906
  # This avoids unnecessary find_or_initialize_by + save! calls on every boot