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:
@ -58,6 +58,7 @@ export class GraphQLConfigService
|
||||
useGraphQLErrorHandlerHook({
|
||||
metricsService: this.metricsService,
|
||||
exceptionHandlerService: this.exceptionHandlerService,
|
||||
twentyConfigService: this.twentyConfigService,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ export const metadataModuleFactory = async (
|
||||
useGraphQLErrorHandlerHook({
|
||||
metricsService: metricsService,
|
||||
exceptionHandlerService,
|
||||
twentyConfigService,
|
||||
}),
|
||||
useCachedMetadata({
|
||||
cacheGetter: cacheStorageService.get.bind(cacheStorageService),
|
||||
|
||||
@ -108,6 +108,9 @@ class PublicFeatureFlag {
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfig {
|
||||
@Field(() => String, { nullable: true })
|
||||
appVersion?: string;
|
||||
|
||||
@Field(() => AuthProviders, { nullable: false })
|
||||
authProviders: AuthProviders;
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user