@@ -1953,7 +1953,7 @@ func TestCacheAnalyticsCollector_HeaderImpactEvents(t *testing.T) {
19531953 c .RecordHeaderImpactEvent (base )
19541954 c .RecordHeaderImpactEvent (base )
19551955 c .RecordHeaderImpactEvent (base )
1956- snap := c .Snapshot ()
1956+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
19571957 assert .Equal (t , []HeaderImpactEvent {base }, snap .HeaderImpactEvents )
19581958 })
19591959
@@ -1963,7 +1963,7 @@ func TestCacheAnalyticsCollector_HeaderImpactEvents(t *testing.T) {
19631963 other .BaseKey = "key2"
19641964 c .RecordHeaderImpactEvent (base )
19651965 c .RecordHeaderImpactEvent (other )
1966- snap := c .Snapshot ()
1966+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
19671967 assert .Equal (t , []HeaderImpactEvent {base , other }, snap .HeaderImpactEvents )
19681968 })
19691969
@@ -1973,7 +1973,7 @@ func TestCacheAnalyticsCollector_HeaderImpactEvents(t *testing.T) {
19731973 other .HeaderHash = 222
19741974 c .RecordHeaderImpactEvent (base )
19751975 c .RecordHeaderImpactEvent (other )
1976- snap := c .Snapshot ()
1976+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
19771977 assert .Equal (t , []HeaderImpactEvent {base , other }, snap .HeaderImpactEvents )
19781978 })
19791979
@@ -1983,7 +1983,7 @@ func TestCacheAnalyticsCollector_HeaderImpactEvents(t *testing.T) {
19831983 other .ResponseHash = 888
19841984 c .RecordHeaderImpactEvent (base )
19851985 c .RecordHeaderImpactEvent (other )
1986- snap := c .Snapshot ()
1986+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
19871987 assert .Equal (t , []HeaderImpactEvent {base , other }, snap .HeaderImpactEvents )
19881988 })
19891989
@@ -1993,7 +1993,7 @@ func TestCacheAnalyticsCollector_HeaderImpactEvents(t *testing.T) {
19931993 other .EntityType = "Product"
19941994 c .RecordHeaderImpactEvent (base )
19951995 c .RecordHeaderImpactEvent (other )
1996- snap := c .Snapshot ()
1996+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
19971997 assert .Equal (t , []HeaderImpactEvent {base , other }, snap .HeaderImpactEvents )
19981998 })
19991999
@@ -2003,14 +2003,14 @@ func TestCacheAnalyticsCollector_HeaderImpactEvents(t *testing.T) {
20032003 other .DataSource = "reviews"
20042004 c .RecordHeaderImpactEvent (base )
20052005 c .RecordHeaderImpactEvent (other )
2006- snap := c .Snapshot ()
2006+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
20072007 assert .Equal (t , []HeaderImpactEvent {base , other }, snap .HeaderImpactEvents )
20082008 })
20092009
20102010 t .Run ("single event is preserved" , func (t * testing.T ) {
20112011 c := NewCacheAnalyticsCollector ()
20122012 c .RecordHeaderImpactEvent (base )
2013- snap := c .Snapshot ()
2013+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
20142014 assert .Equal (t , []HeaderImpactEvent {base }, snap .HeaderImpactEvents )
20152015 })
20162016
@@ -2034,7 +2034,7 @@ func TestCacheAnalyticsCollector_WriteEventSource(t *testing.T) {
20342034 c .RecordWrite (CacheWriteEvent {CacheKey : "key2" , EntityType : "Product" , ByteSize : 256 , DataSource : "products" , CacheLevel : CacheLevelL2 , TTL : 60 * time .Second , Source : CacheSourceMutation })
20352035 c .RecordWrite (CacheWriteEvent {CacheKey : "key3" , EntityType : "Review" , ByteSize : 512 , DataSource : "reviews" , CacheLevel : CacheLevelL2 , TTL : 90 * time .Second , Source : CacheSourceSubscription })
20362036
2037- snap := c .Snapshot ()
2037+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
20382038 // Assert entire L2Writes slice — each event preserves its Source from the recording call
20392039 assert .Equal (t , []CacheWriteEvent {
20402040 {CacheKey : "key1" , EntityType : "User" , ByteSize : 128 , DataSource : "accounts" , CacheLevel : CacheLevelL2 , TTL : 30 * time .Second , Source : CacheSourceQuery }, // Recorded with CacheSourceQuery
@@ -2061,7 +2061,7 @@ func TestCacheAnalyticsCollector_WriteEventSource(t *testing.T) {
20612061 }
20622062 c .RecordMutationEvent (event )
20632063
2064- snap := c .Snapshot ()
2064+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
20652065 // Assert entire MutationEvents slice — Source field preserved through record→snapshot
20662066 assert .Equal (t , []MutationEvent {event }, snap .MutationEvents )
20672067 })
@@ -2073,7 +2073,7 @@ func TestCacheAnalyticsCollector_WriteEventSource(t *testing.T) {
20732073 c .RecordWrite (CacheWriteEvent {CacheKey : "query-key-1" , EntityType : "User" , ByteSize : 128 , DataSource : "accounts" , CacheLevel : CacheLevelL2 , TTL : 30 * time .Second , Source : CacheSourceQuery }) // Write from query resolution
20742074 c .RecordWrite (CacheWriteEvent {CacheKey : "mutation-key-2" , EntityType : "User" , ByteSize : 256 , DataSource : "accounts" , CacheLevel : CacheLevelL2 , TTL : 30 * time .Second , Source : CacheSourceMutation }) // Write from mutation resolution
20752075
2076- snap := c .Snapshot ()
2076+ snap := normalizeCacheAnalyticsSnapshot ( c .Snapshot () )
20772077 // Assert entire L2Writes — different keys prevent deduplication, each retains its Source
20782078 assert .Equal (t , []CacheWriteEvent {
20792079 {CacheKey : "query-key-1" , EntityType : "User" , ByteSize : 128 , DataSource : "accounts" , CacheLevel : CacheLevelL2 , TTL : 30 * time .Second , Source : CacheSourceQuery }, // Query-triggered write
@@ -2345,3 +2345,106 @@ func TestWalkCachedResponseForSources_DataSource(t *testing.T) {
23452345 assert .Equal (t , "accounts" , sources [i ].dataSource , "every entity record must carry the dataSource" )
23462346 }
23472347}
2348+
2349+ // TestHashShadowCachedLeaves verifies that compareShadowValues' recursive
2350+ // per-leaf walk emits one EntityFieldHash per scalar leaf with the accumulated
2351+ // FieldPath, mirroring the fresh-resolution emission in renderFieldValue so
2352+ // shadow-mode comparisons line up by (entityType, fieldName, FieldPath).
2353+ //
2354+ // Before this fix, the shadow loop only iterated info.ProvidesData.Fields
2355+ // at the top level and emitted one whole-subobject hash for nested objects,
2356+ // so nested value-type leaves like User.address.street had no shadow-side
2357+ // counterpart for staleness detection.
2358+ func TestHashShadowCachedLeaves (t * testing.T ) {
2359+ a := arena .NewMonotonicArena (arena .WithMinBufferSize (1024 ))
2360+ parser := & astjson.Parser {}
2361+
2362+ t .Run ("nested value-type leaves emit per-leaf hashes" , func (t * testing.T ) {
2363+ // User { address { street, city } } — Address is a value type under User.
2364+ providesData := & Object {
2365+ Fields : []* Field {
2366+ {Name : []byte ("address" ), Value : & Object {
2367+ Fields : []* Field {
2368+ {Name : []byte ("street" ), Value : & String {}},
2369+ {Name : []byte ("city" ), Value : & String {}},
2370+ },
2371+ }},
2372+ },
2373+ }
2374+ cached , err := parser .ParseBytesWithArena (a , []byte (`{"address":{"street":"1 Main","city":"NYC"}}` ))
2375+ require .NoError (t , err )
2376+
2377+ ctx := NewContext (context .Background ())
2378+ ctx .cacheAnalytics = NewCacheAnalyticsCollector ()
2379+ l := & Loader {ctx : ctx }
2380+ l .hashShadowCachedLeaves (cached , providesData , "User" , `{"id":"1"}` , "accounts" , nil )
2381+
2382+ snap := normalizeCacheAnalyticsSnapshot (ctx .cacheAnalytics .Snapshot ())
2383+ require .Equal (t , 2 , len (snap .FieldHashes ))
2384+ // snapshot is already deduped/ordered by collector internals;
2385+ // sort manually by FieldName for deterministic assertions
2386+ got := make (map [string ]EntityFieldHash , len (snap .FieldHashes ))
2387+ for _ , fh := range snap .FieldHashes {
2388+ got [fh .FieldName ] = fh
2389+ }
2390+ street := got ["street" ]
2391+ assert .Equal (t , "User" , street .EntityType )
2392+ assert .Equal (t , []string {"address" }, street .FieldPath )
2393+ assert .Equal (t , FieldSourceShadowCached , street .Source )
2394+ assert .Equal (t , "accounts" , street .DataSource )
2395+ city := got ["city" ]
2396+ assert .Equal (t , "User" , city .EntityType )
2397+ assert .Equal (t , []string {"address" }, city .FieldPath )
2398+ })
2399+
2400+ t .Run ("__typename is never hashed" , func (t * testing.T ) {
2401+ // __typename is a meta field; the fresh-side resolvable also skips it.
2402+ providesData := & Object {
2403+ Fields : []* Field {
2404+ {Name : []byte ("__typename" ), Value : & String {}},
2405+ {Name : []byte ("username" ), Value : & String {}},
2406+ },
2407+ }
2408+ cached , err := parser .ParseBytesWithArena (a , []byte (`{"__typename":"User","username":"Alice"}` ))
2409+ require .NoError (t , err )
2410+
2411+ ctx := NewContext (context .Background ())
2412+ ctx .cacheAnalytics = NewCacheAnalyticsCollector ()
2413+ l := & Loader {ctx : ctx }
2414+ l .hashShadowCachedLeaves (cached , providesData , "User" , `{"id":"1"}` , "accounts" , nil )
2415+
2416+ snap := normalizeCacheAnalyticsSnapshot (ctx .cacheAnalytics .Snapshot ())
2417+ require .Equal (t , 1 , len (snap .FieldHashes ))
2418+ assert .Equal (t , "username" , snap .FieldHashes [0 ].FieldName , "only username is hashed; __typename is skipped" )
2419+ })
2420+
2421+ t .Run ("array of objects walks each item under the same path" , func (t * testing.T ) {
2422+ // Product { reviews: [Review { body }] } — array of objects.
2423+ // Each review's body should produce one hash with FieldPath ["reviews"].
2424+ providesData := & Object {
2425+ Fields : []* Field {
2426+ {Name : []byte ("reviews" ), Value : & Array {Item : & Object {
2427+ Fields : []* Field {
2428+ {Name : []byte ("body" ), Value : & String {}},
2429+ },
2430+ }}},
2431+ },
2432+ }
2433+ cached , err := parser .ParseBytesWithArena (a , []byte (`{"reviews":[{"body":"first"},{"body":"second"}]}` ))
2434+ require .NoError (t , err )
2435+
2436+ ctx := NewContext (context .Background ())
2437+ ctx .cacheAnalytics = NewCacheAnalyticsCollector ()
2438+ l := & Loader {ctx : ctx }
2439+ l .hashShadowCachedLeaves (cached , providesData , "Product" , `{"upc":"top-1"}` , "reviews" , nil )
2440+
2441+ snap := normalizeCacheAnalyticsSnapshot (ctx .cacheAnalytics .Snapshot ())
2442+ require .Equal (t , 2 , len (snap .FieldHashes ))
2443+ for _ , fh := range snap .FieldHashes {
2444+ assert .Equal (t , "Product" , fh .EntityType )
2445+ assert .Equal (t , "body" , fh .FieldName )
2446+ assert .Equal (t , []string {"reviews" }, fh .FieldPath )
2447+ assert .Equal (t , FieldSourceShadowCached , fh .Source )
2448+ }
2449+ })
2450+ }
0 commit comments