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

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

View File

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

View File

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

View File

@ -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',

View File

@ -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'),

View File

@ -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.`,
},
});
}
}
},
};

View File

@ -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',
}