App version mismatch handling between frontend and backend (#13368)

https://github.com/user-attachments/assets/d153f177-4d70-4ec6-8693-15413e550938
This commit is contained in:
Abdul Rahman
2025-07-24 01:37:02 +05:30
committed by GitHub
parent ed36b19af7
commit 30dd457313
14 changed files with 113 additions and 3 deletions

View File

@ -4,6 +4,7 @@ import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import { MemoryRouter, useLocation } from 'react-router-dom'; import { MemoryRouter, useLocation } from 'react-router-dom';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import { useApolloFactory } from '../useApolloFactory'; import { useApolloFactory } from '../useApolloFactory';
enableFetchMocks(); enableFetchMocks();
@ -25,7 +26,9 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => (
initialEntries={['/welcome', '/verify', '/opportunities']} initialEntries={['/welcome', '/verify', '/opportunities']}
initialIndex={2} initialIndex={2}
> >
{children} <SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="test-instance-id">
{children}
</SnackBarComponentInstanceContextProvider>
</MemoryRouter> </MemoryRouter>
</RecoilRoot> </RecoilRoot>
); );

View File

@ -1,7 +1,7 @@
import { InMemoryCache, NormalizedCacheObject } from '@apollo/client'; import { InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useRecoilState, useSetRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
@ -13,7 +13,9 @@ import { useUpdateEffect } from '~/hooks/useUpdateEffect';
import { isMatchingLocation } from '~/utils/isMatchingLocation'; import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { appVersionState } from '@/client-config/states/appVersionState';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { ApolloFactory, Options } from '../services/apollo.factory'; import { ApolloFactory, Options } from '../services/apollo.factory';
@ -26,6 +28,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
const [currentWorkspace, setCurrentWorkspace] = useRecoilState( const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState, currentWorkspaceState,
); );
const appVersion = useRecoilValue(appVersionState);
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState( const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState, currentWorkspaceMemberState,
); );
@ -35,6 +38,8 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
const setPreviousUrl = useSetRecoilState(previousUrlState); const setPreviousUrl = useSetRecoilState(previousUrlState);
const location = useLocation(); const location = useLocation();
const { enqueueErrorSnackBar } = useSnackBar();
const apolloClient = useMemo(() => { const apolloClient = useMemo(() => {
apolloRef.current = new ApolloFactory({ apolloRef.current = new ApolloFactory({
uri: `${REACT_APP_SERVER_BASE_URL}/graphql`, uri: `${REACT_APP_SERVER_BASE_URL}/graphql`,
@ -54,6 +59,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
connectToDevTools: process.env.IS_DEBUG_MODE === 'true', connectToDevTools: process.env.IS_DEBUG_MODE === 'true',
currentWorkspaceMember: currentWorkspaceMember, currentWorkspaceMember: currentWorkspaceMember,
currentWorkspace: currentWorkspace, currentWorkspace: currentWorkspace,
appVersion,
onTokenPairChange: (tokenPair) => { onTokenPairChange: (tokenPair) => {
setTokenPair(tokenPair); setTokenPair(tokenPair);
}, },
@ -73,6 +79,14 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
navigate(AppPath.SignInUp); navigate(AppPath.SignInUp);
} }
}, },
onAppVersionMismatch: (message) => {
enqueueErrorSnackBar({
message,
options: {
dedupeKey: 'app-version-mismatch',
},
});
},
extraLinks: [], extraLinks: [],
isDebugMode: process.env.IS_DEBUG_MODE === 'true', isDebugMode: process.env.IS_DEBUG_MODE === 'true',
// Override options // Override options
@ -87,6 +101,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
setCurrentWorkspaceMember, setCurrentWorkspaceMember,
setCurrentWorkspace, setCurrentWorkspace,
setPreviousUrl, setPreviousUrl,
enqueueErrorSnackBar,
]); ]);
useUpdateEffect(() => { useUpdateEffect(() => {
@ -101,5 +116,11 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
} }
}, [currentWorkspace]); }, [currentWorkspace]);
useUpdateEffect(() => {
if (isDefined(apolloRef.current)) {
apolloRef.current.updateAppVersion(appVersion);
}
}, [appVersion]);
return apolloClient; return apolloClient;
}; };

View File

@ -64,6 +64,7 @@ const createMockOptions = (): Options<any> => ({
isDebugMode: true, isDebugMode: true,
onError: mockOnError, onError: mockOnError,
onNetworkError: mockOnNetworkError, onNetworkError: mockOnNetworkError,
appVersion: '1.0.0',
}); });
const makeRequest = async () => { const makeRequest = async () => {

View File

@ -23,6 +23,7 @@ import { logDebug } from '~/utils/logDebug';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { import {
DefinitionNode, DefinitionNode,
DirectiveNode, DirectiveNode,
@ -45,16 +46,19 @@ export interface Options<TCacheShape> extends ApolloClientOptions<TCacheShape> {
onNetworkError?: (err: Error | ServerParseError | ServerError) => void; onNetworkError?: (err: Error | ServerParseError | ServerError) => void;
onTokenPairChange?: (tokenPair: AuthTokenPair) => void; onTokenPairChange?: (tokenPair: AuthTokenPair) => void;
onUnauthenticatedError?: () => void; onUnauthenticatedError?: () => void;
onAppVersionMismatch?: (message: string) => void;
currentWorkspaceMember: CurrentWorkspaceMember | null; currentWorkspaceMember: CurrentWorkspaceMember | null;
currentWorkspace: CurrentWorkspace | null; currentWorkspace: CurrentWorkspace | null;
extraLinks?: ApolloLink[]; extraLinks?: ApolloLink[];
isDebugMode?: boolean; isDebugMode?: boolean;
appVersion?: string;
} }
export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> { export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
private client: ApolloClient<TCacheShape>; private client: ApolloClient<TCacheShape>;
private currentWorkspaceMember: CurrentWorkspaceMember | null = null; private currentWorkspaceMember: CurrentWorkspaceMember | null = null;
private currentWorkspace: CurrentWorkspace | null = null; private currentWorkspace: CurrentWorkspace | null = null;
private appVersion?: string;
constructor(opts: Options<TCacheShape>) { constructor(opts: Options<TCacheShape>) {
const { const {
@ -63,15 +67,18 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
onNetworkError, onNetworkError,
onTokenPairChange, onTokenPairChange,
onUnauthenticatedError, onUnauthenticatedError,
onAppVersionMismatch,
currentWorkspaceMember, currentWorkspaceMember,
currentWorkspace, currentWorkspace,
extraLinks, extraLinks,
isDebugMode, isDebugMode,
appVersion,
...options ...options
} = opts; } = opts;
this.currentWorkspaceMember = currentWorkspaceMember; this.currentWorkspaceMember = currentWorkspaceMember;
this.currentWorkspace = currentWorkspace; this.currentWorkspace = currentWorkspace;
this.appVersion = appVersion;
const buildApolloLink = (): ApolloLink => { const buildApolloLink = (): ApolloLink => {
const uploadLink = createUploadLink({ const uploadLink = createUploadLink({
@ -111,6 +118,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
...(this.currentWorkspace?.metadataVersion && { ...(this.currentWorkspace?.metadataVersion && {
'X-Schema-Version': `${this.currentWorkspace.metadataVersion}`, 'X-Schema-Version': `${this.currentWorkspace.metadataVersion}`,
}), }),
...(this.appVersion && { 'X-App-Version': this.appVersion }),
}, },
}; };
}); });
@ -158,6 +166,13 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
} }
switch (graphQLError?.extensions?.code) { switch (graphQLError?.extensions?.code) {
case 'APP_VERSION_MISMATCH': {
onAppVersionMismatch?.(
(graphQLError.extensions?.userFriendlyMessage as string) ||
t`Your app version is out of date. Please refresh the page.`,
);
return;
}
case 'UNAUTHENTICATED': { case 'UNAUTHENTICATED': {
return handleTokenRenewal(operation, forward); return handleTokenRenewal(operation, forward);
} }
@ -289,6 +304,10 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
this.currentWorkspace = workspace; this.currentWorkspace = workspace;
} }
updateAppVersion(appVersion?: string) {
this.appVersion = appVersion;
}
getClient() { getClient() {
return this.client; return this.client;
} }

View File

@ -1,6 +1,7 @@
import { useClientConfig } from '@/client-config/hooks/useClientConfig'; import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { aiModelsState } from '@/client-config/states/aiModelsState'; import { aiModelsState } from '@/client-config/states/aiModelsState';
import { apiConfigState } from '@/client-config/states/apiConfigState'; import { apiConfigState } from '@/client-config/states/apiConfigState';
import { appVersionState } from '@/client-config/states/appVersionState';
import { authProvidersState } from '@/client-config/states/authProvidersState'; import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState'; import { billingState } from '@/client-config/states/billingState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
@ -97,6 +98,8 @@ export const ClientConfigProviderEffect = () => {
isImapSmtpCaldavEnabledState, isImapSmtpCaldavEnabledState,
); );
const setAppVersion = useSetRecoilState(appVersionState);
const { data, loading, error, fetchClientConfig } = useClientConfig(); const { data, loading, error, fetchClientConfig } = useClientConfig();
useEffect(() => { useEffect(() => {
@ -133,7 +136,7 @@ export const ClientConfigProviderEffect = () => {
isErrored: false, isErrored: false,
error: undefined, error: undefined,
})); }));
setAppVersion(data.clientConfig.appVersion);
setAuthProviders({ setAuthProviders({
google: data?.clientConfig.authProviders.google, google: data?.clientConfig.authProviders.google,
microsoft: data?.clientConfig.authProviders.microsoft, microsoft: data?.clientConfig.authProviders.microsoft,
@ -217,6 +220,7 @@ export const ClientConfigProviderEffect = () => {
setIsConfigVariablesInDbEnabled, setIsConfigVariablesInDbEnabled,
setCalendarBookingPageId, setCalendarBookingPageId,
setIsImapSmtpCaldavEnabled, setIsImapSmtpCaldavEnabled,
setAppVersion,
]); ]);
return <></>; return <></>;

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const appVersionState = createState<string | undefined>({
key: 'appVersion',
defaultValue: undefined,
});

View File

@ -10,6 +10,7 @@ import {
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
export type ClientConfig = { export type ClientConfig = {
appVersion?: string;
aiModels: Array<ClientAiModelConfig>; aiModels: Array<ClientAiModelConfig>;
analyticsEnabled: boolean; analyticsEnabled: boolean;
api: ApiConfig; api: ApiConfig;

View File

@ -58,6 +58,7 @@ export class GraphQLConfigService
useGraphQLErrorHandlerHook({ useGraphQLErrorHandlerHook({
metricsService: this.metricsService, metricsService: this.metricsService,
exceptionHandlerService: this.exceptionHandlerService, exceptionHandlerService: this.exceptionHandlerService,
twentyConfigService: this.twentyConfigService,
}), }),
]; ];

View File

@ -39,6 +39,7 @@ export const metadataModuleFactory = async (
useGraphQLErrorHandlerHook({ useGraphQLErrorHandlerHook({
metricsService: metricsService, metricsService: metricsService,
exceptionHandlerService, exceptionHandlerService,
twentyConfigService,
}), }),
useCachedMetadata({ useCachedMetadata({
cacheGetter: cacheStorageService.get.bind(cacheStorageService), cacheGetter: cacheStorageService.get.bind(cacheStorageService),

View File

@ -108,6 +108,9 @@ class PublicFeatureFlag {
@ObjectType() @ObjectType()
export class ClientConfig { export class ClientConfig {
@Field(() => String, { nullable: true })
appVersion?: string;
@Field(() => AuthProviders, { nullable: false }) @Field(() => AuthProviders, { nullable: false })
authProviders: AuthProviders; authProviders: AuthProviders;

View File

@ -100,6 +100,7 @@ describe('ClientConfigService', () => {
const result = await service.getClientConfig(); const result = await service.getClientConfig();
expect(result).toEqual({ expect(result).toEqual({
appVersion: '1.0.0',
billing: { billing: {
isBillingEnabled: true, isBillingEnabled: true,
billingUrl: 'https://billing.example.com', billingUrl: 'https://billing.example.com',

View File

@ -66,6 +66,7 @@ export class ClientConfigService {
} }
const clientConfig: ClientConfig = { const clientConfig: ClientConfig = {
appVersion: this.twentyConfigService.get('APP_VERSION'),
billing: { billing: {
isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'), isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'),
billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'), billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'),

View File

@ -6,6 +6,8 @@ import {
} from '@envelop/core'; } from '@envelop/core';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { GraphQLError, Kind, OperationDefinitionNode, print } from 'graphql'; import { GraphQLError, Kind, OperationDefinitionNode, print } from 'graphql';
import semver from 'semver';
import { isDefined } from 'twenty-shared/utils';
import { GraphQLContext } from 'src/engine/api/graphql/graphql-config/interfaces/graphql-context.interface'; import { GraphQLContext } from 'src/engine/api/graphql/graphql-config/interfaces/graphql-context.interface';
@ -18,6 +20,7 @@ import {
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { import {
graphQLErrorCodesToFilter, graphQLErrorCodesToFilter,
shouldCaptureException, shouldCaptureException,
@ -26,6 +29,9 @@ import {
const DEFAULT_EVENT_ID_KEY = 'exceptionEventId'; const DEFAULT_EVENT_ID_KEY = 'exceptionEventId';
const SCHEMA_VERSION_HEADER = 'x-schema-version'; const SCHEMA_VERSION_HEADER = 'x-schema-version';
const SCHEMA_MISMATCH_ERROR = 'Schema version mismatch.'; const SCHEMA_MISMATCH_ERROR = 'Schema version mismatch.';
const APP_VERSION_HEADER = 'x-app-version';
const APP_VERSION_MISMATCH_ERROR = 'App version mismatch.';
const APP_VERSION_MISMATCH_CODE = 'APP_VERSION_MISMATCH';
type GraphQLErrorHandlerHookOptions = { type GraphQLErrorHandlerHookOptions = {
metricsService: MetricsService; metricsService: MetricsService;
@ -34,6 +40,8 @@ type GraphQLErrorHandlerHookOptions = {
* The exception handler service to use. * The exception handler service to use.
*/ */
exceptionHandlerService: ExceptionHandlerService; exceptionHandlerService: ExceptionHandlerService;
twentyConfigService: TwentyConfigService;
/** /**
* The key of the event id in the error's extension. `null` to disable. * The key of the event id in the error's extension. `null` to disable.
* @default exceptionEventId * @default exceptionEventId
@ -230,17 +238,55 @@ export const useGraphQLErrorHandlerHook = <
const headers = context.req.headers; const headers = context.req.headers;
const currentMetadataVersion = context.req.workspaceMetadataVersion; const currentMetadataVersion = context.req.workspaceMetadataVersion;
const requestMetadataVersion = headers[SCHEMA_VERSION_HEADER]; const requestMetadataVersion = headers[SCHEMA_VERSION_HEADER];
const backendAppVersion =
options.twentyConfigService.get('APP_VERSION');
const appVersionHeaderValue = headers[APP_VERSION_HEADER];
const frontEndAppVersion =
appVersionHeaderValue && Array.isArray(appVersionHeaderValue)
? appVersionHeaderValue[0]
: appVersionHeaderValue;
if ( if (
requestMetadataVersion && requestMetadataVersion &&
requestMetadataVersion !== `${currentMetadataVersion}` requestMetadataVersion !== `${currentMetadataVersion}`
) { ) {
options.metricsService.incrementCounter({
key: MetricsKeys.SchemaVersionMismatch,
});
throw new GraphQLError(SCHEMA_MISMATCH_ERROR, { throw new GraphQLError(SCHEMA_MISMATCH_ERROR, {
extensions: { extensions: {
userFriendlyMessage: t`Your workspace has been updated with a new data model. Please refresh the page.`, userFriendlyMessage: t`Your workspace has been updated with a new data model. Please refresh the page.`,
}, },
}); });
} }
if (
!frontEndAppVersion ||
!backendAppVersion ||
!semver.valid(frontEndAppVersion) ||
!semver.valid(backendAppVersion)
) {
return;
}
const frontEndMajor = semver.parse(frontEndAppVersion)?.major;
const backendMajor = semver.parse(backendAppVersion)?.major;
if (
isDefined(frontEndMajor) &&
isDefined(backendMajor) &&
frontEndMajor < backendMajor
) {
options.metricsService.incrementCounter({
key: MetricsKeys.AppVersionMismatch,
});
throw new GraphQLError(APP_VERSION_MISMATCH_ERROR, {
extensions: {
code: APP_VERSION_MISMATCH_CODE,
userFriendlyMessage: t`Your app version is out of date. Please refresh the page to continue.`,
},
});
}
} }
}, },
}; };

View File

@ -23,4 +23,6 @@ export enum MetricsKeys {
WorkflowRunFailedToEnqueue = 'workflow-run/failed/to-enqueue', WorkflowRunFailedToEnqueue = 'workflow-run/failed/to-enqueue',
AIToolExecutionFailed = 'ai-tool-execution/failed', AIToolExecutionFailed = 'ai-tool-execution/failed',
AIToolExecutionSucceeded = 'ai-tool-execution/succeeded', AIToolExecutionSucceeded = 'ai-tool-execution/succeeded',
SchemaVersionMismatch = 'schema-version/mismatch',
AppVersionMismatch = 'app-version/mismatch',
} }