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.
@@ -9,7 +9,8 @@ module Magick
9
9
  VALID_TYPES = %i[boolean string number].freeze
10
10
  VALID_STATUSES = %i[active inactive deprecated].freeze
11
11
 
12
- attr_reader :name, :type, :status, :default_value, :description, :display_name, :group, :adapter_registry
12
+ attr_reader :name, :type, :status, :default_value, :description, :display_name, :group, :adapter_registry,
13
+ :targeting
13
14
 
14
15
  def initialize(name, adapter_registry, **options)
15
16
  @name = name.to_s
@@ -233,187 +234,246 @@ module Magick
233
234
  end
234
235
 
235
236
  def enable_for_user(user_id)
236
- enable_targeting(:user, user_id)
237
+ record_change('enable_for_user', targeting_change(:user, added: user_id)) do
238
+ enable_targeting(:user, user_id)
239
+ end
237
240
  true
238
241
  end
239
242
 
240
243
  def disable_for_user(user_id)
241
- disable_targeting(:user, user_id)
244
+ record_change('disable_for_user', targeting_change(:user, removed: user_id)) do
245
+ disable_targeting(:user, user_id)
246
+ end
242
247
  true
243
248
  end
244
249
 
245
250
  def enable_for_group(group_name)
246
- enable_targeting(:group, group_name)
251
+ record_change('enable_for_group', targeting_change(:group, added: group_name)) do
252
+ enable_targeting(:group, group_name)
253
+ end
247
254
  true
248
255
  end
249
256
 
250
257
  def disable_for_group(group_name)
251
- disable_targeting(:group, group_name)
258
+ record_change('disable_for_group', targeting_change(:group, removed: group_name)) do
259
+ disable_targeting(:group, group_name)
260
+ end
252
261
  true
253
262
  end
254
263
 
255
264
  def enable_for_role(role_name)
256
- enable_targeting(:role, role_name)
265
+ record_change('enable_for_role', targeting_change(:role, added: role_name)) do
266
+ enable_targeting(:role, role_name)
267
+ end
257
268
  true
258
269
  end
259
270
 
260
271
  def disable_for_role(role_name)
261
- disable_targeting(:role, role_name)
272
+ record_change('disable_for_role', targeting_change(:role, removed: role_name)) do
273
+ disable_targeting(:role, role_name)
274
+ end
262
275
  true
263
276
  end
264
277
 
265
278
  def enable_for_tag(tag_name)
266
- enable_targeting(:tag, tag_name)
279
+ record_change('enable_for_tag', targeting_change(:tag, added: tag_name)) do
280
+ enable_targeting(:tag, tag_name)
281
+ end
267
282
  true
268
283
  end
269
284
 
270
285
  def disable_for_tag(tag_name)
271
- disable_targeting(:tag, tag_name)
286
+ record_change('disable_for_tag', targeting_change(:tag, removed: tag_name)) do
287
+ disable_targeting(:tag, tag_name)
288
+ end
272
289
  true
273
290
  end
274
291
 
275
292
  # --- Exclusion methods ---
276
293
 
277
294
  def exclude_user(user_id)
278
- enable_targeting(:excluded_users, user_id)
295
+ record_change('exclude_user', targeting_change(:excluded_users, added: user_id)) do
296
+ enable_targeting(:excluded_users, user_id)
297
+ end
279
298
  true
280
299
  end
281
300
 
282
301
  def remove_user_exclusion(user_id)
283
- disable_targeting(:excluded_users, user_id)
302
+ record_change('remove_user_exclusion', targeting_change(:excluded_users, removed: user_id)) do
303
+ disable_targeting(:excluded_users, user_id)
304
+ end
284
305
  true
285
306
  end
286
307
 
287
308
  def exclude_tag(tag_name)
288
- enable_targeting(:excluded_tags, tag_name)
309
+ record_change('exclude_tag', targeting_change(:excluded_tags, added: tag_name)) do
310
+ enable_targeting(:excluded_tags, tag_name)
311
+ end
289
312
  true
290
313
  end
291
314
 
292
315
  def remove_tag_exclusion(tag_name)
293
- disable_targeting(:excluded_tags, tag_name)
316
+ record_change('remove_tag_exclusion', targeting_change(:excluded_tags, removed: tag_name)) do
317
+ disable_targeting(:excluded_tags, tag_name)
318
+ end
294
319
  true
295
320
  end
296
321
 
297
322
  def exclude_group(group_name)
298
- enable_targeting(:excluded_groups, group_name)
323
+ record_change('exclude_group', targeting_change(:excluded_groups, added: group_name)) do
324
+ enable_targeting(:excluded_groups, group_name)
325
+ end
299
326
  true
300
327
  end
301
328
 
302
329
  def remove_group_exclusion(group_name)
303
- disable_targeting(:excluded_groups, group_name)
330
+ record_change('remove_group_exclusion', targeting_change(:excluded_groups, removed: group_name)) do
331
+ disable_targeting(:excluded_groups, group_name)
332
+ end
304
333
  true
305
334
  end
306
335
 
307
336
  def exclude_role(role_name)
308
- enable_targeting(:excluded_roles, role_name)
337
+ record_change('exclude_role', targeting_change(:excluded_roles, added: role_name)) do
338
+ enable_targeting(:excluded_roles, role_name)
339
+ end
309
340
  true
310
341
  end
311
342
 
312
343
  def remove_role_exclusion(role_name)
313
- disable_targeting(:excluded_roles, role_name)
344
+ record_change('remove_role_exclusion', targeting_change(:excluded_roles, removed: role_name)) do
345
+ disable_targeting(:excluded_roles, role_name)
346
+ end
314
347
  true
315
348
  end
316
349
 
317
350
  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)
351
+ ips = Array(ip_addresses).map(&:to_s)
352
+ record_change('exclude_ip_addresses', targeting_change(:excluded_ip_addresses, added: ips)) do
353
+ # excluded_ip_addresses is stored as a flat Array of strings; bypass the
354
+ # generic enable_targeting path whose "array type" branch stringifies the
355
+ # incoming array into a single element.
356
+ @targeting[:excluded_ip_addresses] ||= []
357
+ ips.each do |str|
358
+ @targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
359
+ end
360
+ save_targeting
325
361
  end
326
- save_targeting
327
362
  true
328
363
  end
329
364
 
330
365
  def remove_ip_exclusion
331
- disable_targeting(:excluded_ip_addresses)
366
+ record_change('remove_ip_exclusion', targeting_change(:excluded_ip_addresses)) do
367
+ disable_targeting(:excluded_ip_addresses)
368
+ end
332
369
  true
333
370
  end
334
371
 
335
372
  def enable_percentage_of_users(percentage)
336
- @targeting[:percentage_users] = percentage.to_f
337
- save_targeting
373
+ record_change('enable_percentage_of_users', targeting_change(:percentage_users, added: percentage.to_f)) do
374
+ @targeting[:percentage_users] = percentage.to_f
375
+ save_targeting
338
376
 
339
- # Update registered feature instance if it exists
340
- Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
377
+ # Update registered feature instance if it exists
378
+ Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
341
379
 
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)
380
+ # Rails 8+ event
381
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
382
+ Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
383
+ end
345
384
  end
346
385
 
347
386
  true
348
387
  end
349
388
 
350
389
  def disable_percentage_of_users
351
- disable_targeting(:percentage_users)
390
+ record_change('disable_percentage_of_users', targeting_change(:percentage_users)) do
391
+ disable_targeting(:percentage_users)
392
+ end
352
393
  true
353
394
  end
354
395
 
355
396
  def enable_percentage_of_requests(percentage)
356
- @targeting[:percentage_requests] = percentage.to_f
357
- save_targeting
397
+ record_change('enable_percentage_of_requests', targeting_change(:percentage_requests, added: percentage.to_f)) do
398
+ @targeting[:percentage_requests] = percentage.to_f
399
+ save_targeting
358
400
 
359
- # Update registered feature instance if it exists
360
- Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
401
+ # Update registered feature instance if it exists
402
+ Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
361
403
 
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)
404
+ # Rails 8+ event
405
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
406
+ Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
407
+ end
365
408
  end
366
409
 
367
410
  true
368
411
  end
369
412
 
370
413
  def disable_percentage_of_requests
371
- disable_targeting(:percentage_requests)
414
+ record_change('disable_percentage_of_requests', targeting_change(:percentage_requests)) do
415
+ disable_targeting(:percentage_requests)
416
+ end
372
417
  true
373
418
  end
374
419
 
375
420
  def enable_for_date_range(start_date, end_date)
376
- enable_targeting(:date_range, { start: start_date, end: end_date })
421
+ record_change('enable_for_date_range',
422
+ targeting_change(:date_range, added: { start: start_date, end: end_date })) do
423
+ enable_targeting(:date_range, { start: start_date, end: end_date })
424
+ end
377
425
  true
378
426
  end
379
427
 
380
428
  def disable_date_range
381
- disable_targeting(:date_range)
429
+ record_change('disable_date_range', targeting_change(:date_range)) do
430
+ disable_targeting(:date_range)
431
+ end
382
432
  true
383
433
  end
384
434
 
385
435
  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)
436
+ ips = Array(ip_addresses).map(&:to_s)
437
+ record_change('enable_for_ip_addresses', targeting_change(:ip_address, added: ips)) do
438
+ # ip_address is stored as a flat Array of strings; bypass the generic
439
+ # enable_targeting path whose "array type" branch stringifies the
440
+ # incoming array into a single '["x.y.z"]' entry.
441
+ @targeting[:ip_address] ||= []
442
+ ips.each do |str|
443
+ @targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
444
+ end
445
+ save_targeting
393
446
  end
394
- save_targeting
395
447
  true
396
448
  end
397
449
 
398
450
  def disable_ip_addresses
399
- disable_targeting(:ip_address)
451
+ record_change('disable_ip_addresses', targeting_change(:ip_address)) do
452
+ disable_targeting(:ip_address)
453
+ end
400
454
  true
401
455
  end
402
456
 
403
457
  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)
458
+ change = targeting_change(:custom_attributes,
459
+ added: { attribute: attribute_name, values: Array(values), operator: operator })
460
+ record_change('enable_for_custom_attribute', change) do
461
+ custom_attrs = targeting[:custom_attributes] || {}
462
+ custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
463
+ enable_targeting(:custom_attributes, custom_attrs)
464
+ end
407
465
  true
408
466
  end
409
467
 
410
468
  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)
469
+ record_change('disable_custom_attribute', targeting_change(:custom_attributes, removed: attribute_name)) do
470
+ custom_attrs = targeting[:custom_attributes] || {}
471
+ custom_attrs.delete(attribute_name.to_sym)
472
+ if custom_attrs.empty?
473
+ disable_targeting(:custom_attributes)
474
+ else
475
+ enable_targeting(:custom_attributes, custom_attrs)
476
+ end
417
477
  end
418
478
  true
419
479
  end
@@ -422,34 +482,42 @@ module Magick
422
482
  variants_array = Array(variants).map do |v|
423
483
  v.is_a?(FeatureVariant) ? v : FeatureVariant.new(v[:name], v[:value], weight: v[:weight] || 0)
424
484
  end
425
- enable_targeting(:variants, variants_array.map(&:to_h))
485
+ payload = variants_array.map(&:to_h)
426
486
 
427
- # Rails 8+ event
428
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
429
- Magick::Rails::Events.variant_set(name, variants: variants_array)
487
+ record_change('set_variants', { variants: payload }) do
488
+ enable_targeting(:variants, payload)
489
+
490
+ # Rails 8+ event
491
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
492
+ Magick::Rails::Events.variant_set(name, variants: variants_array)
493
+ end
430
494
  end
431
495
 
432
496
  true
433
497
  end
434
498
 
435
499
  def add_dependency(dependency_name)
436
- @dependencies ||= []
437
- @dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s)
500
+ record_change('add_dependency', { dependency: { added: dependency_name.to_s } }) do
501
+ @dependencies ||= []
502
+ @dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s)
438
503
 
439
- # Rails 8+ event
440
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
441
- Magick::Rails::Events.dependency_added(name, dependency_name)
504
+ # Rails 8+ event
505
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
506
+ Magick::Rails::Events.dependency_added(name, dependency_name)
507
+ end
442
508
  end
443
509
 
444
510
  true
445
511
  end
446
512
 
447
513
  def remove_dependency(dependency_name)
448
- @dependencies&.delete(dependency_name.to_s)
514
+ record_change('remove_dependency', { dependency: { removed: dependency_name.to_s } }) do
515
+ @dependencies&.delete(dependency_name.to_s)
449
516
 
450
- # Rails 8+ event
451
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
452
- Magick::Rails::Events.dependency_removed(name, dependency_name)
517
+ # Rails 8+ event
518
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
519
+ Magick::Rails::Events.dependency_removed(name, dependency_name)
520
+ end
453
521
  end
454
522
 
455
523
  true
@@ -501,90 +569,93 @@ module Magick
501
569
  old_value = @stored_value
502
570
  validate_value!(value)
503
571
 
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)
572
+ changes = { value: { from: old_value, to: value } }
510
573
 
511
- @stored_value = value
512
- @stored_value_initialized = true
574
+ record_change('set_value', changes, user_id: user_id) do
575
+ # Bulk write all metadata in a single adapter call instead of 7 separate calls
576
+ data = { 'value' => value, 'type' => type, 'status' => status, 'default_value' => default_value }
577
+ data['description'] = description if description
578
+ data['display_name'] = display_name if display_name
579
+ data['group'] = group if group
580
+ adapter_registry.set_all_data(name, data)
513
581
 
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
521
-
522
- changes = { value: { from: old_value, to: value } }
582
+ @stored_value = value
583
+ @stored_value_initialized = true
523
584
 
524
- Magick.audit_log&.log(
525
- name,
526
- 'set_value',
527
- user_id: user_id,
528
- changes: changes
529
- )
585
+ # Update registered feature instance if it exists
586
+ if Magick.features.key?(name)
587
+ registered = Magick.features[name]
588
+ registered.instance_variable_set(:@stored_value, value)
589
+ registered.instance_variable_set(:@stored_value_initialized, true)
590
+ registered.instance_variable_set(:@targeting, @targeting.dup) if @targeting
591
+ end
530
592
 
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)
593
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
594
+ Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
595
+ end
534
596
  end
535
597
 
536
598
  true
537
599
  end
538
600
 
539
601
  def enable(user_id: nil)
540
- # Clear all targeting to enable globally
541
- @targeting = {}
542
- save_targeting
602
+ changes = { value: { from: @stored_value, to: true }, targeting: { cleared: true } }
543
603
 
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
604
+ record_change('enable', changes, user_id: user_id) do
605
+ # Clear all targeting to enable globally
606
+ @targeting = {}
607
+ save_targeting
608
+
609
+ case type
610
+ when :boolean
611
+ set_value(true, user_id: user_id)
612
+ when :string
613
+ raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.'
614
+ when :number
615
+ raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.'
616
+ else
617
+ raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
618
+ end
554
619
 
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)
620
+ # Rails 8+ event
621
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
622
+ Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
623
+ end
558
624
  end
559
625
 
560
626
  true
561
627
  end
562
628
 
563
629
  def disable(user_id: nil)
564
- # Clear all targeting to disable globally
565
- @targeting = {}
566
- save_targeting
630
+ disabled_value = { boolean: false, string: '', number: 0 }[type]
631
+ changes = { value: { from: @stored_value, to: disabled_value }, targeting: { cleared: true } }
567
632
 
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
633
+ record_change('disable', changes, user_id: user_id) do
634
+ # Clear all targeting to disable globally
635
+ @targeting = {}
636
+ save_targeting
637
+
638
+ case type
639
+ when :boolean
640
+ set_value(false, user_id: user_id)
641
+ when :string
642
+ set_value('', user_id: user_id)
643
+ when :number
644
+ set_value(0, user_id: user_id)
645
+ else
646
+ raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
647
+ end
578
648
 
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
649
+ # Ensure registered feature instance also has targeting cleared
650
+ if Magick.features.key?(name)
651
+ registered = Magick.features[name]
652
+ registered.instance_variable_set(:@targeting, {})
653
+ end
584
654
 
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)
655
+ # Rails 8+ event
656
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
657
+ Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
658
+ end
588
659
  end
589
660
 
590
661
  true
@@ -593,34 +664,41 @@ module Magick
593
664
  def set_status(new_status)
594
665
  raise InvalidFeatureValueError, "Invalid status: #{new_status}" unless VALID_STATUSES.include?(new_status.to_sym)
595
666
 
596
- @status = new_status.to_sym
597
- adapter_registry.set(name, 'status', status)
667
+ record_change('set_status', { status: { from: @status, to: new_status.to_sym } }) do
668
+ @status = new_status.to_sym
669
+ adapter_registry.set(name, 'status', status)
670
+ end
598
671
  true
599
672
  end
600
673
 
601
674
  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
675
+ new_group = group_name.nil? || group_name.to_s.strip.empty? ? nil : group_name.to_s.strip
676
+
677
+ record_change('set_group', { group: { from: @group, to: new_group } }) do
678
+ @group = new_group
679
+ # Clear group from adapter by setting to nil (adapters handle this)
608
680
  adapter_registry.set(name, 'group', @group)
609
- end
610
681
 
611
- # Update registered feature instance if it exists
612
- Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name)
682
+ # Update registered feature instance if it exists
683
+ Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name)
684
+ end
613
685
 
614
686
  true
615
687
  end
616
688
 
617
689
  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)
690
+ # Snapshot before the wipe so the recorded version captures the state
691
+ # this feature had when it was deleted, not the emptied one.
692
+ snapshot = to_h
693
+
694
+ record_change('delete', { deleted: true }, snapshot: snapshot) do
695
+ adapter_registry.delete(name)
696
+ @stored_value = nil
697
+ @stored_value_initialized = false # Reset initialization flag so get_value returns default_value
698
+ @targeting = {}
699
+ # Also remove from Magick.features if registered
700
+ Magick.features.delete(name.to_s)
701
+ end
624
702
  true
625
703
  end
626
704
 
@@ -672,6 +750,64 @@ module Magick
672
750
  }
673
751
  end
674
752
 
753
+ # Wire-format serializer for control-plane APIs (e.g. the platform's
754
+ # /internal/panel/flags endpoints). The "targeting" key is ALWAYS present
755
+ # ({} = no targeting), array rules are arrays of strings, percentages are
756
+ # floats, and the internal :variants entry never appears inside targeting.
757
+ # Rails-idiomatic: `render json: feature` (or a collection) emits this.
758
+ def as_json(_options = nil)
759
+ {
760
+ 'name' => name,
761
+ 'display_name' => display_name,
762
+ 'group' => group,
763
+ 'type' => type.to_s,
764
+ 'status' => status.to_s,
765
+ 'value' => stored_value,
766
+ 'default_value' => default_value,
767
+ 'description' => description,
768
+ 'targeting' => TargetingPayload.serialize(targeting),
769
+ 'dependencies' => (@dependencies || []).map(&:to_s),
770
+ # Variants live inside @targeting under the internal :variants key
771
+ # (variants_for_export reads a never-assigned ivar and is always
772
+ # empty), so the wire payload reads the authoritative source.
773
+ 'variants' => TargetingPayload.deep_stringify(targeting[:variants] || [])
774
+ }
775
+ end
776
+
777
+ # Wholesale, declarative targeting write: the payload IS the new
778
+ # targeting state. Keys absent from it are removed; {} clears all
779
+ # targeting. Accepts wire input leniently (string/symbol keys, plural
780
+ # aliases, scalars for lists, numeric strings) but validates strictly —
781
+ # unknown keys or invalid values raise InvalidTargetingError before any
782
+ # state is touched. The internal :variants entry is not part of the wire
783
+ # payload and survives the replace untouched.
784
+ # Accepts the payload as a positional hash or inline keywords
785
+ # (replace_targeting(user: [3])) — Ruby routes a braceless hash to
786
+ # keywords, so both spellings must land in the same place. Passing
787
+ # nothing raises (via normalize): clearing requires an explicit {}.
788
+ def replace_targeting(payload = nil, user_id: nil, **inline_rules)
789
+ raise ArgumentError, 'pass targeting either as a hash or inline, not both' if payload && inline_rules.any?
790
+
791
+ normalized = TargetingPayload.normalize(payload || (inline_rules unless inline_rules.empty?))
792
+ normalized[:variants] = targeting[:variants] if targeting[:variants]
793
+
794
+ changes = {
795
+ targeting: {
796
+ from: TargetingPayload.serialize(targeting),
797
+ to: TargetingPayload.serialize(normalized)
798
+ }
799
+ }
800
+ record_change('replace_targeting', changes, user_id: user_id) do
801
+ @targeting = normalized
802
+ persist_targeting
803
+
804
+ if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
805
+ Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
806
+ end
807
+ end
808
+ true
809
+ end
810
+
675
811
  def variants_for_export
676
812
  return [] unless defined?(Magick::FeatureVariant)
677
813
 
@@ -686,6 +822,67 @@ module Magick
686
822
  end
687
823
 
688
824
  def save_targeting
825
+ # Records only when called directly (e.g. Admin UI clearing variants);
826
+ # when reached through a wrapping mutator the guard is active and this
827
+ # just persists.
828
+ record_change('update_targeting', { targeting: targeting.dup }) do
829
+ persist_targeting
830
+ end
831
+ end
832
+
833
+ # Restore full feature state from a version snapshot (used by
834
+ # Versioning#rollback). Replaces value (including false/empty), status,
835
+ # group, the entire targeting hash and dependencies wholesale.
836
+ def restore_snapshot!(data)
837
+ set_status(data[:status]) if data[:status]
838
+ set_group(data[:group]) if data.key?(:group)
839
+
840
+ value = data[:value]
841
+ set_value(cast_value(value)) unless value.nil?
842
+
843
+ @targeting = normalize_targeting(data[:targeting])
844
+ save_targeting
845
+
846
+ @dependencies = Array(data[:dependencies]).map(&:to_s)
847
+ true
848
+ end
849
+
850
+ private
851
+
852
+ # Single choke point for change tracking: wraps a public mutator's body,
853
+ # then writes one audit entry and one version snapshot for the operation.
854
+ # A thread-local guard makes nested mutator calls (enable -> set_value)
855
+ # silent, so one logical operation records exactly once, under its real
856
+ # action name. No-op while definitions are being (re)applied at boot.
857
+ def record_change(action, changes = {}, user_id: nil, snapshot: nil)
858
+ if Magick.change_recording_suppressed?
859
+ return block_given? ? yield : true
860
+ end
861
+
862
+ result = block_given? ? Magick.suppress_change_recording { yield } : true
863
+
864
+ actor = user_id || Magick.current_actor
865
+ Magick.audit_log&.log(name, action, user_id: actor, changes: changes)
866
+
867
+ if Magick.versioning_enabled?
868
+ begin
869
+ Magick.versioning&.record_change(self, action: action, created_by: actor, snapshot: snapshot)
870
+ rescue StandardError => e
871
+ warn "Magick: Failed to record version for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
872
+ end
873
+ end
874
+
875
+ result
876
+ end
877
+
878
+ def targeting_change(type, added: nil, removed: nil)
879
+ change = { type: type }
880
+ change[:added] = added unless added.nil?
881
+ change[:removed] = removed unless removed.nil?
882
+ { targeting: change }
883
+ end
884
+
885
+ def persist_targeting
689
886
  # Save targeting to adapter (this updates memory synchronously, then Redis/AR)
690
887
  # The set method already publishes cache invalidation to other processes via Pub/Sub
691
888
  adapter_registry.set(name, 'targeting', targeting)
@@ -706,10 +903,6 @@ module Magick
706
903
  # 3. The set method handles both sync and async Redis updates correctly
707
904
  end
708
905
 
709
- private
710
-
711
- attr_reader :targeting
712
-
713
906
  def stored_value
714
907
  return @stored_value if @stored_value_initialized
715
908
 
@@ -748,14 +941,7 @@ module Magick
748
941
  group_value = all_data['group']
749
942
  @group = group_value if group_value
750
943
 
751
- targeting_value = all_data['targeting']
752
- if targeting_value.is_a?(Hash)
753
- @targeting = targeting_value.transform_keys(&:to_sym)
754
- @targeting[:percentage_users] = @targeting[:percentage_users].to_f if @targeting[:percentage_users]
755
- @targeting[:percentage_requests] = @targeting[:percentage_requests].to_f if @targeting[:percentage_requests]
756
- else
757
- @targeting = {}
758
- end
944
+ @targeting = normalize_targeting(all_data['targeting'])
759
945
 
760
946
  # Track what was loaded so save_metadata_if_new can skip unnecessary writes
761
947
  @_loaded_description = all_data['description']
@@ -763,6 +949,15 @@ module Magick
763
949
  @_loaded_group = all_data['group']
764
950
  end
765
951
 
952
+ def normalize_targeting(raw)
953
+ return {} unless raw.is_a?(Hash)
954
+
955
+ normalized = raw.transform_keys(&:to_sym)
956
+ normalized[:percentage_users] = normalized[:percentage_users].to_f if normalized[:percentage_users]
957
+ normalized[:percentage_requests] = normalized[:percentage_requests].to_f if normalized[:percentage_requests]
958
+ normalized
959
+ end
960
+
766
961
  def save_metadata_if_new
767
962
  # Only write to adapter if the DSL value differs from what was loaded
768
963
  # This avoids unnecessary find_or_initialize_by + save! calls on every boot