From 30dd457313d3e167fd2f4817e052ac51071f907e Mon Sep 17 00:00:00 2001
From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com>
Date: Thu, 24 Jul 2025 01:37:02 +0530
Subject: [PATCH] App version mismatch handling between frontend and backend
(#13368)
https://github.com/user-attachments/assets/d153f177-4d70-4ec6-8693-15413e550938
---
.../hooks/__tests__/useApolloFactory.test.tsx | 5 +-
.../modules/apollo/hooks/useApolloFactory.ts | 23 +++++++++-
.../services/__tests__/apollo.factory.test.ts | 1 +
.../modules/apollo/services/apollo.factory.ts | 19 ++++++++
.../components/ClientConfigProviderEffect.tsx | 6 ++-
.../client-config/states/appVersionState.ts | 6 +++
.../client-config/types/ClientConfig.ts | 1 +
.../graphql-config/graphql-config.service.ts | 1 +
.../api/graphql/metadata.module-factory.ts | 1 +
.../client-config/client-config.entity.ts | 3 ++
.../services/client-config.service.spec.ts | 1 +
.../services/client-config.service.ts | 1 +
.../hooks/use-graphql-error-handler.hook.ts | 46 +++++++++++++++++++
.../metrics/types/metrics-keys.type.ts | 2 +
14 files changed, 113 insertions(+), 3 deletions(-)
create mode 100644 packages/twenty-front/src/modules/client-config/states/appVersionState.ts
diff --git a/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx b/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx
index 5e19a8309..d5a4c2b20 100644
--- a/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx
+++ b/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx
@@ -4,6 +4,7 @@ import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
+import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import { useApolloFactory } from '../useApolloFactory';
enableFetchMocks();
@@ -25,7 +26,9 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => (
initialEntries={['/welcome', '/verify', '/opportunities']}
initialIndex={2}
>
- {children}
+
+ {children}
+
);
diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts
index 55c71bffb..861b4d032 100644
--- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts
+++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts
@@ -1,7 +1,7 @@
import { InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { useMemo, useRef } from 'react';
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 { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
@@ -13,7 +13,9 @@ import { useUpdateEffect } from '~/hooks/useUpdateEffect';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
+import { appVersionState } from '@/client-config/states/appVersionState';
import { AppPath } from '@/types/AppPath';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefined } from 'twenty-shared/utils';
import { ApolloFactory, Options } from '../services/apollo.factory';
@@ -26,6 +28,7 @@ export const useApolloFactory = (options: Partial> = {}) => {
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
+ const appVersion = useRecoilValue(appVersionState);
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState,
);
@@ -35,6 +38,8 @@ export const useApolloFactory = (options: Partial> = {}) => {
const setPreviousUrl = useSetRecoilState(previousUrlState);
const location = useLocation();
+ const { enqueueErrorSnackBar } = useSnackBar();
+
const apolloClient = useMemo(() => {
apolloRef.current = new ApolloFactory({
uri: `${REACT_APP_SERVER_BASE_URL}/graphql`,
@@ -54,6 +59,7 @@ export const useApolloFactory = (options: Partial> = {}) => {
connectToDevTools: process.env.IS_DEBUG_MODE === 'true',
currentWorkspaceMember: currentWorkspaceMember,
currentWorkspace: currentWorkspace,
+ appVersion,
onTokenPairChange: (tokenPair) => {
setTokenPair(tokenPair);
},
@@ -73,6 +79,14 @@ export const useApolloFactory = (options: Partial> = {}) => {
navigate(AppPath.SignInUp);
}
},
+ onAppVersionMismatch: (message) => {
+ enqueueErrorSnackBar({
+ message,
+ options: {
+ dedupeKey: 'app-version-mismatch',
+ },
+ });
+ },
extraLinks: [],
isDebugMode: process.env.IS_DEBUG_MODE === 'true',
// Override options
@@ -87,6 +101,7 @@ export const useApolloFactory = (options: Partial> = {}) => {
setCurrentWorkspaceMember,
setCurrentWorkspace,
setPreviousUrl,
+ enqueueErrorSnackBar,
]);
useUpdateEffect(() => {
@@ -101,5 +116,11 @@ export const useApolloFactory = (options: Partial> = {}) => {
}
}, [currentWorkspace]);
+ useUpdateEffect(() => {
+ if (isDefined(apolloRef.current)) {
+ apolloRef.current.updateAppVersion(appVersion);
+ }
+ }, [appVersion]);
+
return apolloClient;
};
diff --git a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts
index 9ea6e72a8..9a8b9c4b6 100644
--- a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts
+++ b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts
@@ -64,6 +64,7 @@ const createMockOptions = (): Options => ({
isDebugMode: true,
onError: mockOnError,
onNetworkError: mockOnNetworkError,
+ appVersion: '1.0.0',
});
const makeRequest = async () => {
diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts
index cddab824c..788b555ec 100644
--- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts
+++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts
@@ -23,6 +23,7 @@ import { logDebug } from '~/utils/logDebug';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { i18n } from '@lingui/core';
+import { t } from '@lingui/core/macro';
import {
DefinitionNode,
DirectiveNode,
@@ -45,16 +46,19 @@ export interface Options extends ApolloClientOptions {
onNetworkError?: (err: Error | ServerParseError | ServerError) => void;
onTokenPairChange?: (tokenPair: AuthTokenPair) => void;
onUnauthenticatedError?: () => void;
+ onAppVersionMismatch?: (message: string) => void;
currentWorkspaceMember: CurrentWorkspaceMember | null;
currentWorkspace: CurrentWorkspace | null;
extraLinks?: ApolloLink[];
isDebugMode?: boolean;
+ appVersion?: string;
}
export class ApolloFactory implements ApolloManager {
private client: ApolloClient;
private currentWorkspaceMember: CurrentWorkspaceMember | null = null;
private currentWorkspace: CurrentWorkspace | null = null;
+ private appVersion?: string;
constructor(opts: Options) {
const {
@@ -63,15 +67,18 @@ export class ApolloFactory implements ApolloManager {
onNetworkError,
onTokenPairChange,
onUnauthenticatedError,
+ onAppVersionMismatch,
currentWorkspaceMember,
currentWorkspace,
extraLinks,
isDebugMode,
+ appVersion,
...options
} = opts;
this.currentWorkspaceMember = currentWorkspaceMember;
this.currentWorkspace = currentWorkspace;
+ this.appVersion = appVersion;
const buildApolloLink = (): ApolloLink => {
const uploadLink = createUploadLink({
@@ -111,6 +118,7 @@ export class ApolloFactory implements ApolloManager {
...(this.currentWorkspace?.metadataVersion && {
'X-Schema-Version': `${this.currentWorkspace.metadataVersion}`,
}),
+ ...(this.appVersion && { 'X-App-Version': this.appVersion }),
},
};
});
@@ -158,6 +166,13 @@ export class ApolloFactory implements ApolloManager {
}
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': {
return handleTokenRenewal(operation, forward);
}
@@ -289,6 +304,10 @@ export class ApolloFactory implements ApolloManager {
this.currentWorkspace = workspace;
}
+ updateAppVersion(appVersion?: string) {
+ this.appVersion = appVersion;
+ }
+
getClient() {
return this.client;
}
diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx
index 9ea12fcd5..977bd22e3 100644
--- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx
+++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx
@@ -1,6 +1,7 @@
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { apiConfigState } from '@/client-config/states/apiConfigState';
+import { appVersionState } from '@/client-config/states/appVersionState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
@@ -97,6 +98,8 @@ export const ClientConfigProviderEffect = () => {
isImapSmtpCaldavEnabledState,
);
+ const setAppVersion = useSetRecoilState(appVersionState);
+
const { data, loading, error, fetchClientConfig } = useClientConfig();
useEffect(() => {
@@ -133,7 +136,7 @@ export const ClientConfigProviderEffect = () => {
isErrored: false,
error: undefined,
}));
-
+ setAppVersion(data.clientConfig.appVersion);
setAuthProviders({
google: data?.clientConfig.authProviders.google,
microsoft: data?.clientConfig.authProviders.microsoft,
@@ -217,6 +220,7 @@ export const ClientConfigProviderEffect = () => {
setIsConfigVariablesInDbEnabled,
setCalendarBookingPageId,
setIsImapSmtpCaldavEnabled,
+ setAppVersion,
]);
return <>>;
diff --git a/packages/twenty-front/src/modules/client-config/states/appVersionState.ts b/packages/twenty-front/src/modules/client-config/states/appVersionState.ts
new file mode 100644
index 000000000..5b226d7f9
--- /dev/null
+++ b/packages/twenty-front/src/modules/client-config/states/appVersionState.ts
@@ -0,0 +1,6 @@
+import { createState } from 'twenty-ui/utilities';
+
+export const appVersionState = createState({
+ key: 'appVersion',
+ defaultValue: undefined,
+});
diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
index 77c01f8ed..3286f7800 100644
--- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
+++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
@@ -10,6 +10,7 @@ import {
} from '~/generated-metadata/graphql';
export type ClientConfig = {
+ appVersion?: string;
aiModels: Array;
analyticsEnabled: boolean;
api: ApiConfig;
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts
index 1d36c2a41..755bb13f0 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts
@@ -58,6 +58,7 @@ export class GraphQLConfigService
useGraphQLErrorHandlerHook({
metricsService: this.metricsService,
exceptionHandlerService: this.exceptionHandlerService,
+ twentyConfigService: this.twentyConfigService,
}),
];
diff --git a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts
index 6f5abd9e4..1d52b8c24 100644
--- a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts
@@ -39,6 +39,7 @@ export const metadataModuleFactory = async (
useGraphQLErrorHandlerHook({
metricsService: metricsService,
exceptionHandlerService,
+ twentyConfigService,
}),
useCachedMetadata({
cacheGetter: cacheStorageService.get.bind(cacheStorageService),
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
index c13668547..99de96cd5 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
@@ -108,6 +108,9 @@ class PublicFeatureFlag {
@ObjectType()
export class ClientConfig {
+ @Field(() => String, { nullable: true })
+ appVersion?: string;
+
@Field(() => AuthProviders, { nullable: false })
authProviders: AuthProviders;
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
index 3e24331c5..09c2cae06 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
@@ -100,6 +100,7 @@ describe('ClientConfigService', () => {
const result = await service.getClientConfig();
expect(result).toEqual({
+ appVersion: '1.0.0',
billing: {
isBillingEnabled: true,
billingUrl: 'https://billing.example.com',
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
index a895c7880..7529f1521 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
@@ -66,6 +66,7 @@ export class ClientConfigService {
}
const clientConfig: ClientConfig = {
+ appVersion: this.twentyConfigService.get('APP_VERSION'),
billing: {
isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'),
billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'),
diff --git a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts
index 3b0f60426..c57df028f 100644
--- a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts
+++ b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts
@@ -6,6 +6,8 @@ import {
} from '@envelop/core';
import { t } from '@lingui/core/macro';
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';
@@ -18,6 +20,7 @@ import {
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
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 {
graphQLErrorCodesToFilter,
shouldCaptureException,
@@ -26,6 +29,9 @@ import {
const DEFAULT_EVENT_ID_KEY = 'exceptionEventId';
const SCHEMA_VERSION_HEADER = 'x-schema-version';
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 = {
metricsService: MetricsService;
@@ -34,6 +40,8 @@ type GraphQLErrorHandlerHookOptions = {
* The exception handler service to use.
*/
exceptionHandlerService: ExceptionHandlerService;
+
+ twentyConfigService: TwentyConfigService;
/**
* The key of the event id in the error's extension. `null` to disable.
* @default exceptionEventId
@@ -230,17 +238,55 @@ export const useGraphQLErrorHandlerHook = <
const headers = context.req.headers;
const currentMetadataVersion = context.req.workspaceMetadataVersion;
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 (
requestMetadataVersion &&
requestMetadataVersion !== `${currentMetadataVersion}`
) {
+ options.metricsService.incrementCounter({
+ key: MetricsKeys.SchemaVersionMismatch,
+ });
throw new GraphQLError(SCHEMA_MISMATCH_ERROR, {
extensions: {
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.`,
+ },
+ });
+ }
}
},
};
diff --git a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts
index 7422a5690..e13f451d1 100644
--- a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts
+++ b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts
@@ -23,4 +23,6 @@ export enum MetricsKeys {
WorkflowRunFailedToEnqueue = 'workflow-run/failed/to-enqueue',
AIToolExecutionFailed = 'ai-tool-execution/failed',
AIToolExecutionSucceeded = 'ai-tool-execution/succeeded',
+ SchemaVersionMismatch = 'schema-version/mismatch',
+ AppVersionMismatch = 'app-version/mismatch',
}