From 2774175809d5423a9543578bd8c77481bb306251 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:41:29 +0300 Subject: [PATCH 1/2] Add legacy fallback for feed v2 --- __tests__/feeds.ts | 69 +++++++++++++++++ src/schema/feedV2.ts | 33 +++++++- src/schema/feeds.ts | 174 ++++++++++++++++++++++++++++++++----------- 3 files changed, 229 insertions(+), 47 deletions(-) diff --git a/__tests__/feeds.ts b/__tests__/feeds.ts index 6f893c527f..b81d9bef99 100644 --- a/__tests__/feeds.ts +++ b/__tests__/feeds.ts @@ -1605,6 +1605,75 @@ describe('query feedV2', () => { }, ]); }); + + it('should fall back to the local feed resolver for legacy versions', async () => { + loggedUser = '1'; + await saveFeedFixtures(); + + const fallbackQuery = ` + query FeedFallback($first: Int, $version: Int) { + feed(first: $first, version: $version, unreadOnly: false) { + pageInfo { + endCursor + hasNextPage + } + edges { + cursor + node { + id + title + type + feedMeta + } + } + } + feedV2(first: $first, version: $version, unreadOnly: false) { + pageInfo { + endCursor + hasNextPage + } + edges { + cursor + node { + __typename + ... on FeedPostItem { + feedMeta + post { + id + title + type + } + } + } + } + } + } + `; + + const res = await client.query(fallbackQuery, { + variables: { + first: 10, + version: 1, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.feedV2.pageInfo).toEqual(res.data.feed.pageInfo); + expect(res.data.feedV2.edges).toEqual( + res.data.feed.edges.map((edge) => ({ + cursor: edge.cursor, + node: { + __typename: 'FeedPostItem', + feedMeta: edge.node.feedMeta, + post: { + id: edge.node.id, + title: edge.node.title, + type: edge.node.type, + }, + }, + })), + ); + }); }); describe('query feedByConfig', () => { diff --git a/src/schema/feedV2.ts b/src/schema/feedV2.ts index 6b45b2253c..014abf8bbf 100644 --- a/src/schema/feedV2.ts +++ b/src/schema/feedV2.ts @@ -5,6 +5,7 @@ import graphorm from '../graphorm'; import type { AuthContext, BaseContext } from '../Context'; import type { GQLPost } from './posts'; import type { FeedGenerator, FeedResponse } from '../integrations/feed'; +import type { Connection } from 'graphql-relay'; import { isFeedResponseHighlightItem, versionToFeedGenerator, @@ -171,7 +172,7 @@ const supportsHighlights = ({ }: Pick): boolean => !!supportedTypes?.includes('highlight') && !!highlightsLimit; -const getAllowedPostTypes = ( +export const getFeedV2AllowedPostTypes = ( supportedTypes: FeedV2Args['supportedTypes'], ): string[] | undefined => supportedTypes?.filter((type) => type !== 'highlight'); @@ -192,7 +193,7 @@ const getResolveTreeChild = ( ); }; -const getFeedV2FieldTree = ( +export const getFeedV2FieldTree = ( info: GraphQLResolveInfo, typeName: 'FeedPostItem' | 'FeedHighlightsItem', fieldName: 'post' | 'highlights', @@ -248,6 +249,32 @@ const toFeedV2Items = ({ return acc; }, []); +export const toFeedV2PostConnection = ( + connection: Connection, +): Connection => ({ + pageInfo: connection.pageInfo, + edges: connection.edges.map((edge) => ({ + cursor: edge.cursor, + node: { + itemType: 'post', + postId: edge.node.id, + post: edge.node, + feedMeta: edge.node.feedMeta ?? null, + }, + })), +}); + +export const emptyFeedV2Connection = ({ + after, +}: Pick): Connection => + graphorm.nodesToConnection( + [], + 0, + () => !!after, + () => false, + () => after || '', + ); + export const feedV2QueryResolver: IFieldResolver< unknown, AuthContext, @@ -257,7 +284,7 @@ export const feedV2QueryResolver: IFieldResolver< limit: Math.min(args.first || 30, 50), cursor: args.after || undefined, }; - const allowedPostTypes = getAllowedPostTypes(args.supportedTypes); + const allowedPostTypes = getFeedV2AllowedPostTypes(args.supportedTypes); const shouldApplyNoAi = args.noAi || (await isSavedNoAiEnabled(ctx)); const response = await getForYouFeedGenerator({ ...args, diff --git a/src/schema/feeds.ts b/src/schema/feeds.ts index 433fd870ba..8b773b69a4 100644 --- a/src/schema/feeds.ts +++ b/src/schema/feeds.ts @@ -41,6 +41,7 @@ import { import { In, Not, SelectQueryBuilder } from 'typeorm'; import { ensureSourcePermissions, GQLSource } from './sources'; import { + connectionFromNodes, CursorPage, feedCursorPageGenerator, GQLEmptyResponse, @@ -87,12 +88,16 @@ import { SourceMemberRoles } from '../roles'; import { ContentPreferenceKeyword } from '../entity/contentPreference/ContentPreferenceKeyword'; import { briefingPostIdsMaxItems } from '../common/brief'; import { + emptyFeedV2Connection, feedV2QueryResolver, feedV2Resolvers, feedV2TypeDefs, type FeedV2Args, + getFeedV2AllowedPostTypes, + getFeedV2FieldTree, getForYouFeedGenerator, isSavedNoAiEnabled, + toFeedV2PostConnection, } from './feedV2'; interface GQLTagsCategory { @@ -1163,6 +1168,10 @@ interface FeedPage extends Page { score?: number; } +type FeedVersionArgs = Pick & { + version: number; +}; + const feedPageGenerator: PageGenerator = { connArgsToPage: ({ ranking, first, after }: FeedArgs) => { const cursor = getCursorFromAfter(after || undefined); @@ -1219,6 +1228,44 @@ const applyFeedPaging = ( return newBuilder; }; +const shouldUseFeedGenerator = ({ version, ranking }: FeedVersionArgs) => { + if (version >= 2 && ranking === Ranking.POPULARITY) { + return true; + } + + if (version >= 2 && ranking === Ranking.TIME) { + return true; + } + + return false; +}; + +const getConfiguredFeedId = ( + ctx: Pick, + args: Pick | Pick, +): string | undefined => args.feedId || ctx.userId; + +const getConfiguredFeedQueryParams = ( + ctx: Context, + args: Pick | Pick, +) => feedToFilters(ctx.con, getConfiguredFeedId(ctx, args), ctx.userId); + +const buildConfiguredFeedQuery = ( + ctx: Context, + args: Pick | FeedV2Args, + builder: SelectQueryBuilder, + alias: string, + queryParams: AnonymousFeedFilters | undefined, +) => + configuredFeedBuilder( + ctx, + getConfiguredFeedId(ctx, args), + args.unreadOnly, + builder, + alias, + queryParams, + ); + interface UpvotedPage extends Page { timestamp?: Date; } @@ -1387,27 +1434,86 @@ const feedResolverV1: IFieldResolver = builder, alias, queryParams: AnonymousFeedFilters | undefined, - ) => { - const feedId = args.feedId || ctx.userId; - - return configuredFeedBuilder( - ctx, - feedId, - args.unreadOnly, - builder, - alias, - queryParams, - ); - }, + ) => buildConfiguredFeedQuery(ctx, args, builder, alias, queryParams), feedPageGenerator, applyFeedPaging, { - fetchQueryParams: async (ctx, args) => - feedToFilters(ctx.con, args.feedId || ctx.userId, ctx.userId), + fetchQueryParams: (ctx, args) => getConfiguredFeedQueryParams(ctx, args), allowPrivatePosts: false, }, ); +const feedResolverV2Local: IFieldResolver< + unknown, + AuthContext, + FeedV2Args +> = async (source, args, ctx, info) => { + const postFieldTree = getFeedV2FieldTree(info, 'FeedPostItem', 'post'); + const allowedPostTypes = getFeedV2AllowedPostTypes(args.supportedTypes); + + if ((args.supportedTypes && !allowedPostTypes?.length) || !postFieldTree) { + return emptyFeedV2Connection(args); + } + + const page = feedPageGenerator.connArgsToPage(args); + const queryParams = await getConfiguredFeedQueryParams(ctx, args); + const supportedTypes = allowedPostTypes?.filter((type) => { + return queryParams.excludeTypes + ? !queryParams.excludeTypes.includes(type) + : true; + }); + const sourceTypes = queryParams.excludeSourceTypes?.length + ? baseFeedConfig.source_types?.filter( + (type) => !queryParams.excludeSourceTypes?.includes(type), + ) || [] + : []; + + const nodes = await graphorm.queryResolveTree( + ctx, + postFieldTree, + (builder) => { + builder.queryBuilder = applyFeedWhere( + ctx, + applyFeedPaging( + ctx, + args, + page, + buildConfiguredFeedQuery( + ctx, + args, + builder.queryBuilder, + builder.alias, + queryParams, + ), + builder.alias, + ), + builder.alias, + supportedTypes || ['article'], + true, + true, + false, + true, + true, + sourceTypes, + ); + return builder; + }, + true, + ); + + return toFeedV2PostConnection( + connectionFromNodes( + args, + nodes, + undefined, + page, + feedPageGenerator, + undefined, + queryParams, + ), + ); +}; + const feedResolverCursor = feedResolver< unknown, FeedArgs & { generator: FeedGenerator }, @@ -1554,23 +1660,15 @@ const postRepostsFeedResolver = feedResolver( export const resolvers: IResolvers = { Query: { anonymousFeed: (source, args: AnonymousFeedArgs, ctx: Context, info) => { - if (args.version >= 2 && args.ranking === Ranking.POPULARITY) { - return feedResolverCursor( - source, - { - ...(args as FeedArgs), - generator: feedGenerators['popular']!, - }, - ctx, - info, - ); - } - if (args.version >= 2 && args.ranking === Ranking.TIME) { + if (shouldUseFeedGenerator(args)) { return feedResolverCursor( source, { ...(args as FeedArgs), - generator: feedGenerators['time']!, + generator: + args.ranking === Ranking.TIME + ? feedGenerators['time']! + : feedGenerators['popular']!, }, ctx, info, @@ -1579,22 +1677,8 @@ export const resolvers: IResolvers = { return anonymousFeedResolverV1(source, args, ctx, info); }, feed: async (source, args: ConfiguredFeedArgs, ctx: AuthContext, info) => { - const shouldApplyNoAi = args.noAi || (await isSavedNoAiEnabled(ctx)); - if (args.version >= 2 && args.ranking === Ranking.POPULARITY) { - return feedResolverCursor( - source, - { - ...(args as FeedArgs), - generator: getForYouFeedGenerator({ - ...args, - noAi: shouldApplyNoAi, - }), - }, - ctx, - info, - ); - } - if (args.version >= 2 && args.ranking === Ranking.TIME) { + if (shouldUseFeedGenerator(args)) { + const shouldApplyNoAi = args.noAi || (await isSavedNoAiEnabled(ctx)); return feedResolverCursor( source, { @@ -1611,7 +1695,9 @@ export const resolvers: IResolvers = { return feedResolverV1(source, args, ctx, info); }, feedV2: (source, args: FeedV2Args, ctx: AuthContext, info) => - feedV2QueryResolver(source, args, ctx, info), + shouldUseFeedGenerator(args) + ? feedV2QueryResolver(source, args, ctx, info) + : feedResolverV2Local(source, args, ctx, info), followingFeed: async (source, args: FeedArgs, ctx: Context, info) => { return feedResolverCursor( source, From 02745d02cfc18a5f6a928f9cb30db8e8ea1c225e Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:47:36 +0300 Subject: [PATCH 2/2] refactor: simplify condition --- src/schema/feeds.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/schema/feeds.ts b/src/schema/feeds.ts index 8b773b69a4..a7eb5442e2 100644 --- a/src/schema/feeds.ts +++ b/src/schema/feeds.ts @@ -1228,16 +1228,8 @@ const applyFeedPaging = ( return newBuilder; }; -const shouldUseFeedGenerator = ({ version, ranking }: FeedVersionArgs) => { - if (version >= 2 && ranking === Ranking.POPULARITY) { - return true; - } - - if (version >= 2 && ranking === Ranking.TIME) { - return true; - } - - return false; +const shouldUseFeedGenerator = ({ version }: FeedVersionArgs) => { + return version >= 2; }; const getConfiguredFeedId = (