@@ -413,6 +413,139 @@ defmodule Electric.Client.TagTrackerTest do
413413 assert dp == [ [ 0 ] , [ 1 ] ]
414414 end
415415
416+ test "multi-disjunct: row stays when one disjunct lost, deleted when all lost" do
417+ # Tags: ["hash_a/hash_b/", "//hash_c"]
418+ # Disjunct 0 covers positions [0, 1], disjunct 1 covers position [2]
419+ msg =
420+ make_change_msg ( "key1" , :insert ,
421+ tags: [ "hash_a/hash_b/" , "//hash_c" ] ,
422+ active_conditions: [ true , true , true ] ,
423+ value: % { "id" => "1" , "name" => "User 1" }
424+ )
425+
426+ { ttk , kd , dp } = TagTracker . update_tag_index ( % { } , % { } , nil , msg )
427+ assert dp == [ [ 0 , 1 ] , [ 2 ] ]
428+
429+ # Move-out at position 0 → disjunct 0 fails (needs [0,1]), disjunct 1 (pos 2) still satisfied
430+ patterns = [ % { pos: 0 , value: "hash_a" } ]
431+ timestamp = DateTime . utc_now ( )
432+
433+ { deletes , ttk , kd } =
434+ TagTracker . generate_synthetic_deletes ( ttk , kd , dp , patterns , timestamp )
435+
436+ assert deletes == [ ]
437+ assert kd [ "key1" ] . active_conditions == [ false , true , true ]
438+
439+ # Move-out at position 2 → disjunct 1 also fails, no disjunct satisfied
440+ patterns = [ % { pos: 2 , value: "hash_c" } ]
441+
442+ { deletes , _ttk , _kd } =
443+ TagTracker . generate_synthetic_deletes ( ttk , kd , dp , patterns , timestamp )
444+
445+ assert length ( deletes ) == 1
446+ assert hd ( deletes ) . key == "key1"
447+ end
448+
449+ test "overwrite active_conditions when row is re-sent (move-in overwrite)" do
450+ # Insert row with active_conditions [true, false]
451+ msg1 =
452+ make_change_msg ( "key1" , :insert ,
453+ tags: [ "hash_a/hash_b" ] ,
454+ active_conditions: [ true , false ] ,
455+ value: % { "id" => "1" , "name" => "User 1" }
456+ )
457+
458+ { ttk , kd , dp } = TagTracker . update_tag_index ( % { } , % { } , nil , msg1 )
459+ assert kd [ "key1" ] . active_conditions == [ true , false ]
460+
461+ # Server re-sends the same row with updated active_conditions
462+ msg2 =
463+ make_change_msg ( "key1" , :update ,
464+ tags: [ "hash_a/hash_b" ] ,
465+ active_conditions: [ true , true ] ,
466+ value: % { "id" => "1" , "name" => "User 1 updated" }
467+ )
468+
469+ { ttk , kd , dp } = TagTracker . update_tag_index ( ttk , kd , dp , msg2 )
470+ assert kd [ "key1" ] . active_conditions == [ true , true ]
471+
472+ # Verify the overwritten active_conditions work correctly:
473+ # With single disjunct [0,1], move-out at pos 0 should make row invisible
474+ patterns = [ % { pos: 0 , value: "hash_a" } ]
475+ timestamp = DateTime . utc_now ( )
476+
477+ { deletes , _ttk , _kd } =
478+ TagTracker . generate_synthetic_deletes ( ttk , kd , dp , patterns , timestamp )
479+
480+ assert length ( deletes ) == 1
481+ assert hd ( deletes ) . key == "key1"
482+ end
483+
484+ test "delete on empty tag set for simple shapes (no active_conditions)" do
485+ # Insert row with a single-position tag but NO active_conditions
486+ msg =
487+ make_change_msg ( "key1" , :insert ,
488+ tags: [ "hash1" ] ,
489+ value: % { "id" => "1" , "name" => "User 1" }
490+ )
491+
492+ { ttk , kd , dp } = TagTracker . update_tag_index ( % { } , % { } , nil , msg )
493+ assert kd [ "key1" ] . active_conditions == nil
494+
495+ # Move-out at position 0 — no active_conditions: tag removed, tag set empty → delete
496+ patterns = [ % { pos: 0 , value: "hash1" } ]
497+ timestamp = DateTime . utc_now ( )
498+
499+ { deletes , new_ttk , new_kd } =
500+ TagTracker . generate_synthetic_deletes ( ttk , kd , dp , patterns , timestamp )
501+
502+ assert length ( deletes ) == 1
503+ assert hd ( deletes ) . key == "key1"
504+ assert new_kd == % { }
505+ assert new_ttk == % { }
506+ end
507+
508+ test "mixed rows: some with active_conditions, some without" do
509+ # Row 1: DNF shape (with active_conditions)
510+ msg1 =
511+ make_change_msg ( "key1" , :insert ,
512+ tags: [ "hash_a/" , "/hash_b" ] ,
513+ active_conditions: [ true , true ] ,
514+ value: % { "id" => "1" , "name" => "DNF User" }
515+ )
516+
517+ # Row 2: simple shape (single-position tag, no active_conditions)
518+ msg2 =
519+ make_change_msg ( "key2" , :insert ,
520+ tags: [ "hash_a" ] ,
521+ value: % { "id" => "2" , "name" => "Simple User" }
522+ )
523+
524+ { ttk , kd , dp } = TagTracker . update_tag_index ( % { } , % { } , nil , msg1 )
525+ { ttk , kd , dp } = TagTracker . update_tag_index ( ttk , kd , dp , msg2 )
526+
527+ assert Map . has_key? ( kd , "key1" )
528+ assert Map . has_key? ( kd , "key2" )
529+
530+ # Move-out at position 0 with value hash_a
531+ # DNF row: disjunct 0 ([0]) fails, but disjunct 1 ([1]) still satisfied → stays
532+ # Simple row: tag "hash_a" at pos 0 removed, tag set empty → deleted
533+ patterns = [ % { pos: 0 , value: "hash_a" } ]
534+ timestamp = DateTime . utc_now ( )
535+
536+ { deletes , _ttk , new_kd } =
537+ TagTracker . generate_synthetic_deletes ( ttk , kd , dp , patterns , timestamp )
538+
539+ # DNF row stays, simple row deleted
540+ deleted_keys = Enum . map ( deletes , & & 1 . key ) |> MapSet . new ( )
541+ assert MapSet . member? ( deleted_keys , "key2" )
542+ refute MapSet . member? ( deleted_keys , "key1" )
543+
544+ assert Map . has_key? ( new_kd , "key1" )
545+ assert new_kd [ "key1" ] . active_conditions == [ false , true ]
546+ refute Map . has_key? ( new_kd , "key2" )
547+ end
548+
416549 test "disjunct_positions derived once and reused across keys" do
417550 msg1 =
418551 make_change_msg ( "key1" , :insert ,
0 commit comments