Add workspace DDL lock env var and maintenance mode UI (#19130)
## Summary - Add `WORKSPACE_SCHEMA_DDL_LOCKED` env-only boolean config variable that blocks all workspace schema DDL changes when set to `true`. This is intended for hot upgrades where logical replication cannot handle DDL changes. Enforced at two chokepoints: - `WorkspaceMigrationRunnerService.run` — blocks all metadata-driven DDL (object/field/index CRUD, app sync/uninstall, standard app sync, upgrade commands) - `WorkspaceDataSourceService.createWorkspaceDBSchema` / `deleteWorkspaceDBSchema` — blocks workspace creation (sign-up) and hard deletion. Uses a dedicated `WorkspaceDataSourceException` (not ForbiddenException) - Add maintenance mode feature with Admin Panel UI and user-facing banner: - **Backend**: `MaintenanceModeService` stores maintenance window (startAt, endAt, optional link) in `core.keyValuePair` as `CONFIG_VARIABLE`. Validates endAt > startAt. Uses `GraphQLISODateTime` scalar for date fields. Exposed via `clientConfig` REST endpoint and admin GraphQL mutations (`setMaintenanceMode`, `clearMaintenanceMode`) - **Admin Panel**: New "Maintenance Mode" section in Health tab with UTC datetime pickers and activate/deactivate controls - **Banner**: `InformationBannerMaintenance` displayed at the top of `DefaultLayout` for all users, using Temporal API for timezone-aware formatting with an optional "Learn more" link These two features are **independent** — the DDL lock is controlled via env var for operational use, while maintenance mode is a UI notification mechanism controlled from the admin panel.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
} from 'src/utils/custom-exception';
|
||||
|
||||
export const AdminPanelExceptionCode = appendCommonExceptionCode({
|
||||
INVALID_MAINTENANCE_MODE_TIME_RANGE: 'INVALID_MAINTENANCE_MODE_TIME_RANGE',
|
||||
} as const);
|
||||
|
||||
const getAdminPanelExceptionUserFriendlyMessage = (
|
||||
code: keyof typeof AdminPanelExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case AdminPanelExceptionCode.INVALID_MAINTENANCE_MODE_TIME_RANGE:
|
||||
return msg`Please choose an end date and time after the start date and time.`;
|
||||
case AdminPanelExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
return msg`Something went wrong. Please try again.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class AdminPanelException extends CustomException<
|
||||
keyof typeof AdminPanelExceptionCode
|
||||
> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: keyof typeof AdminPanelExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ?? getAdminPanelExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/adm
|
||||
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
|
||||
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
@@ -24,7 +25,9 @@ import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-clie
|
||||
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
|
||||
import { UsageModule } from 'src/engine/core-modules/usage/usage.module';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
@@ -44,18 +47,21 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
SecureHttpClientModule,
|
||||
ApplicationRegistrationModule,
|
||||
UsageModule,
|
||||
KeyValuePairModule,
|
||||
UserVarsModule,
|
||||
],
|
||||
providers: [
|
||||
AdminPanelResolver,
|
||||
AdminPanelService,
|
||||
AdminPanelHealthService,
|
||||
AdminPanelQueueService,
|
||||
MaintenanceModeService,
|
||||
DatabaseHealthIndicator,
|
||||
RedisHealthIndicator,
|
||||
WorkerHealthIndicator,
|
||||
ConnectedAccountHealth,
|
||||
AppHealthIndicator,
|
||||
],
|
||||
exports: [AdminPanelService],
|
||||
exports: [AdminPanelService, MaintenanceModeService],
|
||||
})
|
||||
export class AdminPanelModule {}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
|
||||
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
|
||||
@@ -55,9 +56,11 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
import { AdminPanelHealthServiceDataDTO } from './dtos/admin-panel-health-service-data.dto';
|
||||
import { MaintenanceModeDTO } from './dtos/maintenance-mode.dto';
|
||||
import { ModelsDevModelSuggestionDTO } from './dtos/models-dev-model-suggestion.dto';
|
||||
import { ModelsDevProviderSuggestionDTO } from './dtos/models-dev-provider-suggestion.dto';
|
||||
import { QueueMetricsDataDTO } from './dtos/queue-metrics-data.dto';
|
||||
import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@@ -82,6 +85,7 @@ export class AdminPanelResolver {
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly modelsDevCatalogService: ModelsDevCatalogService,
|
||||
private readonly usageAnalyticsService: UsageAnalyticsService,
|
||||
private readonly maintenanceModeService: MaintenanceModeService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {}
|
||||
@@ -551,4 +555,42 @@ export class AdminPanelResolver {
|
||||
label: nameMap.get(item.key),
|
||||
}));
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Query(() => MaintenanceModeDTO, { nullable: true })
|
||||
async getMaintenanceMode(): Promise<MaintenanceModeDTO | null> {
|
||||
const value = await this.maintenanceModeService.getMaintenanceMode();
|
||||
|
||||
if (!isDefined(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startAt: new Date(value.startAt),
|
||||
endAt: new Date(value.endAt),
|
||||
link: value.link,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => Boolean)
|
||||
async setMaintenanceMode(
|
||||
@Args() { startAt, endAt, link }: SetMaintenanceModeInput,
|
||||
): Promise<boolean> {
|
||||
await this.maintenanceModeService.setMaintenanceMode({
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
link,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => Boolean)
|
||||
async clearMaintenanceMode(): Promise<boolean> {
|
||||
await this.maintenanceModeService.clearMaintenanceMode();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Field, GraphQLISODateTime, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('MaintenanceMode')
|
||||
export class MaintenanceModeDTO {
|
||||
@Field(() => GraphQLISODateTime)
|
||||
startAt: Date;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
endAt: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
link?: string;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { ArgsType, Field, GraphQLISODateTime } from '@nestjs/graphql';
|
||||
|
||||
import { IsDate, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@ArgsType()
|
||||
export class SetMaintenanceModeInput {
|
||||
@Field(() => GraphQLISODateTime)
|
||||
@IsNotEmpty()
|
||||
@Type(() => Date)
|
||||
@IsDate()
|
||||
startAt: Date;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
@IsNotEmpty()
|
||||
@Type(() => Date)
|
||||
@IsDate()
|
||||
endAt: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
link?: string;
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
AdminPanelException,
|
||||
AdminPanelExceptionCode,
|
||||
} from 'src/engine/core-modules/admin-panel/admin-panel.exception';
|
||||
import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { KeyValuePairService } from 'src/engine/core-modules/key-value-pair/key-value-pair.service';
|
||||
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
|
||||
|
||||
import {
|
||||
MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
|
||||
type MaintenanceModeBannerKeyValueTypeMap,
|
||||
} from './types/maintenance-mode-banner-key-value.type';
|
||||
|
||||
const MAINTENANCE_MODE_KEY = 'MAINTENANCE_MODE';
|
||||
|
||||
type MaintenanceModeValue = {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
type MaintenanceModeKeyValueTypeMap = {
|
||||
[MAINTENANCE_MODE_KEY]: MaintenanceModeValue;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceModeService {
|
||||
constructor(
|
||||
private readonly keyValuePairService: KeyValuePairService<MaintenanceModeKeyValueTypeMap>,
|
||||
private readonly userVarsService: UserVarsService<MaintenanceModeBannerKeyValueTypeMap>,
|
||||
) {}
|
||||
|
||||
private async clearMaintenanceModeBannerDismissals(): Promise<void> {
|
||||
await this.userVarsService.delete({
|
||||
key: MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
async getMaintenanceMode(): Promise<MaintenanceModeValue | null> {
|
||||
const maintenanceModeKeyValuePairs = await this.keyValuePairService.get({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: MAINTENANCE_MODE_KEY,
|
||||
});
|
||||
|
||||
if (maintenanceModeKeyValuePairs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = (
|
||||
maintenanceModeKeyValuePairs[0] as {
|
||||
value?: MaintenanceModeKeyValueTypeMap[typeof MAINTENANCE_MODE_KEY];
|
||||
}
|
||||
)?.value;
|
||||
|
||||
if (
|
||||
!isDefined(value) ||
|
||||
!isNonEmptyString(value.startAt) ||
|
||||
!isNonEmptyString(value.endAt)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
async setMaintenanceMode(value: MaintenanceModeValue): Promise<void> {
|
||||
if (new Date(value.endAt) <= new Date(value.startAt)) {
|
||||
throw new AdminPanelException(
|
||||
'Maintenance mode end date must be after start date',
|
||||
AdminPanelExceptionCode.INVALID_MAINTENANCE_MODE_TIME_RANGE,
|
||||
);
|
||||
}
|
||||
|
||||
await this.clearMaintenanceModeBannerDismissals();
|
||||
|
||||
await this.keyValuePairService.set({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: MAINTENANCE_MODE_KEY,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
async clearMaintenanceMode(): Promise<void> {
|
||||
await this.clearMaintenanceModeBannerDismissals();
|
||||
|
||||
await this.keyValuePairService.delete({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: MAINTENANCE_MODE_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
async isMaintenanceModeBannerDismissed(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
const isDismissed = await this.userVarsService.get({
|
||||
userId,
|
||||
workspaceId,
|
||||
key: MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
|
||||
});
|
||||
|
||||
return isDismissed === true;
|
||||
}
|
||||
|
||||
async dismissMaintenanceModeBanner(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.userVarsService.set({
|
||||
userId,
|
||||
workspaceId,
|
||||
key: MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export const MAINTENANCE_MODE_BANNER_DISMISSED_KEY =
|
||||
'MAINTENANCE_MODE_BANNER_DISMISSED';
|
||||
|
||||
export type MaintenanceModeBannerKeyValueTypeMap = {
|
||||
[MAINTENANCE_MODE_BANNER_DISMISSED_KEY]: boolean;
|
||||
};
|
||||
+1
@@ -99,6 +99,7 @@ describe('ClientConfigController', () => {
|
||||
allowRequestsToTwentyIcons: true,
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
isWorkspaceSchemaDDLLocked: false,
|
||||
};
|
||||
|
||||
jest
|
||||
|
||||
+24
-1
@@ -1,4 +1,9 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import {
|
||||
Field,
|
||||
GraphQLISODateTime,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
@@ -212,6 +217,18 @@ export class PublicFeatureFlag {
|
||||
metadata: PublicFeatureFlagMetadata;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfigMaintenanceMode {
|
||||
@Field(() => GraphQLISODateTime)
|
||||
startAt: Date;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
endAt: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
link?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfig {
|
||||
@Field(() => String, { nullable: true })
|
||||
@@ -294,4 +311,10 @@ export class ClientConfig {
|
||||
|
||||
@Field(() => Boolean)
|
||||
isClickHouseConfigured: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isWorkspaceSchemaDDLLocked: boolean;
|
||||
|
||||
@Field(() => ClientConfigMaintenanceMode, { nullable: true })
|
||||
maintenance?: ClientConfigMaintenanceMode;
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,14 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
|
||||
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
|
||||
|
||||
import { ClientConfigController } from './client-config.controller';
|
||||
import { ClientConfigResolver } from './client-config.resolver';
|
||||
|
||||
import { ClientConfigService } from './services/client-config.service';
|
||||
|
||||
@Module({
|
||||
imports: [DomainServerConfigModule],
|
||||
imports: [DomainServerConfigModule, AdminPanelModule],
|
||||
controllers: [ClientConfigController],
|
||||
providers: [ClientConfigService],
|
||||
providers: [ClientConfigResolver, ClientConfigService],
|
||||
})
|
||||
export class ClientConfigModule {}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { ClientConfigResolver } from './client-config.resolver';
|
||||
|
||||
describe('ClientConfigResolver', () => {
|
||||
let resolver: ClientConfigResolver;
|
||||
let maintenanceModeService: MaintenanceModeService;
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-id',
|
||||
} as AuthContextUser;
|
||||
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-id',
|
||||
} as WorkspaceEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClientConfigResolver,
|
||||
{
|
||||
provide: MaintenanceModeService,
|
||||
useValue: {
|
||||
dismissMaintenanceModeBanner: jest.fn(),
|
||||
isMaintenanceModeBannerDismissed: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<ClientConfigResolver>(ClientConfigResolver);
|
||||
maintenanceModeService = module.get<MaintenanceModeService>(
|
||||
MaintenanceModeService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return dismissal state from maintenance mode service', async () => {
|
||||
jest
|
||||
.spyOn(maintenanceModeService, 'isMaintenanceModeBannerDismissed')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await resolver.isMaintenanceModeBannerDismissed(
|
||||
mockUser,
|
||||
mockWorkspace,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(
|
||||
maintenanceModeService.isMaintenanceModeBannerDismissed,
|
||||
).toHaveBeenCalledWith(mockUser.id, mockWorkspace.id);
|
||||
});
|
||||
|
||||
it('should persist dismissal through maintenance mode service', async () => {
|
||||
jest
|
||||
.spyOn(maintenanceModeService, 'dismissMaintenanceModeBanner')
|
||||
.mockResolvedValue();
|
||||
|
||||
const result = await resolver.dismissMaintenanceModeBanner(
|
||||
mockUser,
|
||||
mockWorkspace,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(
|
||||
maintenanceModeService.dismissMaintenanceModeBanner,
|
||||
).toHaveBeenCalledWith(mockUser.id, mockWorkspace.id);
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { CoreResolver } from 'src/engine/api/graphql/graphql-config/decorators/core-resolver.decorator';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
@CoreResolver()
|
||||
export class ClientConfigResolver {
|
||||
constructor(
|
||||
private readonly maintenanceModeService: MaintenanceModeService,
|
||||
) {}
|
||||
|
||||
@Query(() => Boolean)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async isMaintenanceModeBannerDismissed(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
return this.maintenanceModeService.isMaintenanceModeBannerDismissed(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async dismissMaintenanceModeBanner(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
await this.maintenanceModeService.dismissMaintenanceModeBanner(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+10
@@ -9,6 +9,7 @@ import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
|
||||
describe('ClientConfigService', () => {
|
||||
let service: ClientConfigService;
|
||||
@@ -39,6 +40,12 @@ describe('ClientConfigService', () => {
|
||||
getModelConfig: jest.fn().mockReturnValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MaintenanceModeService,
|
||||
useValue: {
|
||||
getMaintenanceMode: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -91,6 +98,8 @@ describe('ClientConfigService', () => {
|
||||
CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
|
||||
CLOUDFLARE_API_KEY: undefined,
|
||||
CLOUDFLARE_ZONE_ID: undefined,
|
||||
ALLOW_REQUESTS_TO_TWENTY_ICONS: false,
|
||||
CLICKHOUSE_URL: undefined,
|
||||
};
|
||||
|
||||
return mockValues[key];
|
||||
@@ -159,6 +168,7 @@ describe('ClientConfigService', () => {
|
||||
isGoogleCalendarEnabled: true,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
allowRequestsToTwentyIcons: false,
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
|
||||
+17
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import {
|
||||
type ClientAIModelConfig,
|
||||
type ClientConfig,
|
||||
@@ -33,6 +35,7 @@ export class ClientConfigService {
|
||||
private twentyConfigService: TwentyConfigService,
|
||||
private domainServerConfigService: DomainServerConfigService,
|
||||
private aiModelRegistryService: AiModelRegistryService,
|
||||
private maintenanceModeService: MaintenanceModeService,
|
||||
) {}
|
||||
|
||||
private deriveNativeCapabilities(
|
||||
@@ -237,8 +240,22 @@ export class ClientConfigService {
|
||||
: undefined,
|
||||
isCloudflareIntegrationEnabled: this.isCloudflareIntegrationEnabled(),
|
||||
isClickHouseConfigured: !!this.twentyConfigService.get('CLICKHOUSE_URL'),
|
||||
isWorkspaceSchemaDDLLocked: this.twentyConfigService.get(
|
||||
'WORKSPACE_SCHEMA_DDL_LOCKED',
|
||||
),
|
||||
};
|
||||
|
||||
const maintenanceMode =
|
||||
await this.maintenanceModeService.getMaintenanceMode();
|
||||
|
||||
if (isDefined(maintenanceMode)) {
|
||||
clientConfig.maintenance = {
|
||||
startAt: new Date(maintenanceMode.startAt),
|
||||
endAt: new Date(maintenanceMode.endAt),
|
||||
link: maintenanceMode.link,
|
||||
};
|
||||
}
|
||||
|
||||
return clientConfig;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-13
@@ -1,6 +1,6 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { type I18n, type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import {
|
||||
@@ -8,12 +8,8 @@ import {
|
||||
ErrorCode,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { convertExceptionToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export const generateGraphQLErrorFromError = (
|
||||
error: Error | CustomException,
|
||||
i18n: I18n,
|
||||
) => {
|
||||
export const generateGraphQLErrorFromError = (error: Error, i18n: I18n) => {
|
||||
const graphqlError =
|
||||
error instanceof HttpException
|
||||
? convertExceptionToGraphQLError(error)
|
||||
@@ -21,13 +17,14 @@ export const generateGraphQLErrorFromError = (
|
||||
|
||||
const defaultErrorMessage = msg`An error occurred.`;
|
||||
|
||||
if (error instanceof CustomException) {
|
||||
graphqlError.extensions.userFriendlyMessage = i18n._(
|
||||
error.userFriendlyMessage ?? defaultErrorMessage,
|
||||
);
|
||||
} else {
|
||||
graphqlError.extensions.userFriendlyMessage = i18n._(defaultErrorMessage);
|
||||
}
|
||||
const userFriendlyMessage =
|
||||
'userFriendlyMessage' in error
|
||||
? (error.userFriendlyMessage as MessageDescriptor)
|
||||
: undefined;
|
||||
|
||||
graphqlError.extensions.userFriendlyMessage = i18n._(
|
||||
userFriendlyMessage ?? defaultErrorMessage,
|
||||
);
|
||||
|
||||
return graphqlError;
|
||||
};
|
||||
|
||||
@@ -47,6 +47,14 @@ export enum KeyValuePairType {
|
||||
where: '"workspaceId" is NULL',
|
||||
},
|
||||
)
|
||||
@Index(
|
||||
'IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE',
|
||||
['key'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"userId" is NULL AND "workspaceId" is NULL',
|
||||
},
|
||||
)
|
||||
export class KeyValuePairEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
KeyValuePairEntity,
|
||||
KeyValuePairType,
|
||||
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
|
||||
import { KeyValuePairService } from './key-value-pair.service';
|
||||
|
||||
describe('KeyValuePairService', () => {
|
||||
let service: KeyValuePairService;
|
||||
let keyValuePairRepository: jest.Mocked<Repository<KeyValuePairEntity>>;
|
||||
|
||||
beforeEach(() => {
|
||||
keyValuePairRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
upsert: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as jest.Mocked<Repository<KeyValuePairEntity>>;
|
||||
|
||||
service = new KeyValuePairService(keyValuePairRepository);
|
||||
});
|
||||
|
||||
it('should insert a global null/null key when missing', async () => {
|
||||
await service.set({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
key: 'MAINTENANCE_MODE',
|
||||
value: { startAt: '2026-04-02T10:00:00.000Z' },
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
});
|
||||
|
||||
expect(keyValuePairRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: expect.any(Object),
|
||||
workspaceId: expect.any(Object),
|
||||
key: 'MAINTENANCE_MODE',
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
},
|
||||
});
|
||||
expect(keyValuePairRepository.insert).toHaveBeenCalledWith({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
key: 'MAINTENANCE_MODE',
|
||||
value: { startAt: '2026-04-02T10:00:00.000Z' },
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
});
|
||||
expect(keyValuePairRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a global null/null key when present', async () => {
|
||||
keyValuePairRepository.findOne.mockResolvedValue({
|
||||
id: 'existing-id',
|
||||
} as KeyValuePairEntity);
|
||||
|
||||
await service.set({
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
key: 'MAINTENANCE_MODE',
|
||||
value: { startAt: '2026-04-02T10:00:00.000Z' },
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
});
|
||||
|
||||
expect(keyValuePairRepository.update).toHaveBeenCalledWith('existing-id', {
|
||||
value: { startAt: '2026-04-02T10:00:00.000Z' },
|
||||
});
|
||||
expect(keyValuePairRepository.insert).not.toHaveBeenCalled();
|
||||
expect(keyValuePairRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep the existing workspace-null index behavior', async () => {
|
||||
await service.set({
|
||||
userId: 'user-id',
|
||||
workspaceId: null,
|
||||
key: 'USER_SETTING',
|
||||
value: true,
|
||||
type: KeyValuePairType.USER_VARIABLE,
|
||||
});
|
||||
|
||||
expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
|
||||
{
|
||||
userId: 'user-id',
|
||||
workspaceId: null,
|
||||
key: 'USER_SETTING',
|
||||
value: true,
|
||||
type: KeyValuePairType.USER_VARIABLE,
|
||||
},
|
||||
{
|
||||
conflictPaths: ['userId', 'workspaceId', 'key'],
|
||||
indexPredicate: '"workspaceId" is NULL',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+46
-23
@@ -66,40 +66,63 @@ export class KeyValuePairService<
|
||||
},
|
||||
queryRunner?: QueryRunner,
|
||||
) {
|
||||
const normalizedUserId = userId ?? null;
|
||||
const normalizedWorkspaceId = workspaceId ?? null;
|
||||
const hasNullUserAndWorkspace =
|
||||
normalizedUserId === null && normalizedWorkspaceId === null;
|
||||
const keyValuePairRepository = queryRunner
|
||||
? queryRunner.manager.getRepository(KeyValuePairEntity)
|
||||
: this.keyValuePairRepository;
|
||||
|
||||
const upsertData = {
|
||||
userId,
|
||||
workspaceId,
|
||||
userId: normalizedUserId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
};
|
||||
|
||||
if (hasNullUserAndWorkspace) {
|
||||
const existingKeyValuePair = await keyValuePairRepository.findOne({
|
||||
where: {
|
||||
userId: IsNull(),
|
||||
workspaceId: IsNull(),
|
||||
key,
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingKeyValuePair) {
|
||||
await keyValuePairRepository.update(existingKeyValuePair.id, {
|
||||
value,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await keyValuePairRepository.insert(upsertData);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const conflictPaths = Object.keys(upsertData).filter(
|
||||
(key) =>
|
||||
['userId', 'workspaceId', 'key'].includes(key) &&
|
||||
(conflictPath) =>
|
||||
['userId', 'workspaceId', 'key'].includes(conflictPath) &&
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
upsertData[key] !== undefined,
|
||||
upsertData[conflictPath] !== undefined,
|
||||
);
|
||||
|
||||
const indexPredicate = !userId
|
||||
? '"userId" is NULL'
|
||||
: !workspaceId
|
||||
? '"workspaceId" is NULL'
|
||||
: undefined;
|
||||
const indexPredicate =
|
||||
normalizedUserId === null
|
||||
? '"userId" is NULL'
|
||||
: normalizedWorkspaceId === null
|
||||
? '"workspaceId" is NULL'
|
||||
: undefined;
|
||||
|
||||
if (queryRunner) {
|
||||
await queryRunner.manager
|
||||
.getRepository(KeyValuePairEntity)
|
||||
.upsert(upsertData, {
|
||||
conflictPaths,
|
||||
indexPredicate,
|
||||
});
|
||||
} else {
|
||||
await this.keyValuePairRepository.upsert(upsertData, {
|
||||
conflictPaths,
|
||||
indexPredicate,
|
||||
});
|
||||
}
|
||||
await keyValuePairRepository.upsert(upsertData, {
|
||||
conflictPaths,
|
||||
indexPredicate,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(
|
||||
|
||||
@@ -81,6 +81,16 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
OUTBOUND_HTTP_SAFE_MODE_ENABLED = true;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
|
||||
description:
|
||||
'Lock workspace schema DDL changes (for hot upgrades). Blocks sign-up, workspace deletion, and all metadata schema changes.',
|
||||
isEnvOnly: true,
|
||||
type: ConfigVariableType.BOOLEAN,
|
||||
})
|
||||
@IsOptional()
|
||||
WORKSPACE_SCHEMA_DDL_LOCKED = false;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.TOKENS_DURATION,
|
||||
description: 'Duration for which the email verification token is valid',
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable, CustomError } from 'twenty-shared/utils';
|
||||
|
||||
export const WorkspaceDataSourceExceptionCode = {
|
||||
DDL_LOCKED: 'DDL_LOCKED',
|
||||
} as const;
|
||||
|
||||
const getWorkspaceDataSourceExceptionUserFriendlyMessage = (
|
||||
code: keyof typeof WorkspaceDataSourceExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case WorkspaceDataSourceExceptionCode.DDL_LOCKED:
|
||||
return msg`Workspace schema changes are temporarily locked.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class WorkspaceDataSourceException extends CustomError {
|
||||
code: keyof typeof WorkspaceDataSourceExceptionCode;
|
||||
userFriendlyMessage: MessageDescriptor;
|
||||
|
||||
constructor({
|
||||
message,
|
||||
code,
|
||||
userFriendlyMessage,
|
||||
}: {
|
||||
message: string;
|
||||
code: keyof typeof WorkspaceDataSourceExceptionCode;
|
||||
userFriendlyMessage?: MessageDescriptor;
|
||||
}) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.userFriendlyMessage =
|
||||
userFriendlyMessage ??
|
||||
getWorkspaceDataSourceExceptionUserFriendlyMessage(code);
|
||||
}
|
||||
}
|
||||
+20
@@ -7,12 +7,17 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { type DataSource, type EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import {
|
||||
WorkspaceDataSourceException,
|
||||
WorkspaceDataSourceExceptionCode,
|
||||
} from 'src/engine/workspace-datasource/exceptions/workspace-datasource.exception';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
|
||||
@Injectable()
|
||||
@@ -24,8 +29,19 @@ export class WorkspaceDataSourceService {
|
||||
private readonly coreDataSource: DataSource,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
private assertDDLNotLocked(): void {
|
||||
if (this.twentyConfigService.get('WORKSPACE_SCHEMA_DDL_LOCKED')) {
|
||||
throw new WorkspaceDataSourceException({
|
||||
message:
|
||||
'Workspace schema DDL changes are locked. This is typically set during hot upgrades.',
|
||||
code: WorkspaceDataSourceExceptionCode.DDL_LOCKED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async checkSchemaExists(workspaceId: string) {
|
||||
const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_DATASOURCE_MIGRATED,
|
||||
@@ -57,6 +73,8 @@ export class WorkspaceDataSourceService {
|
||||
* @returns
|
||||
*/
|
||||
public async createWorkspaceDBSchema(workspaceId: string): Promise<string> {
|
||||
this.assertDDLNotLocked();
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
@@ -77,6 +95,8 @@ export class WorkspaceDataSourceService {
|
||||
* @returns
|
||||
*/
|
||||
public async deleteWorkspaceDBSchema(workspaceId: string): Promise<void> {
|
||||
this.assertDDLNotLocked();
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
|
||||
+3
@@ -8,6 +8,7 @@ export const WorkspaceMigrationRunnerExceptionCode = {
|
||||
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||
EXECUTION_FAILED: 'EXECUTION_FAILED',
|
||||
APPLICATION_NOT_FOUND: 'APPLICATION_NOT_FOUND',
|
||||
DDL_LOCKED: 'DDL_LOCKED',
|
||||
} as const;
|
||||
|
||||
const getWorkspaceMigrationRunnerExceptionUserFriendlyMessage = (
|
||||
@@ -20,6 +21,8 @@ const getWorkspaceMigrationRunnerExceptionUserFriendlyMessage = (
|
||||
return msg`Migration execution failed.`;
|
||||
case WorkspaceMigrationRunnerExceptionCode.APPLICATION_NOT_FOUND:
|
||||
return msg`Application not found.`;
|
||||
case WorkspaceMigrationRunnerExceptionCode.DDL_LOCKED:
|
||||
return msg`Workspace schema changes are temporarily locked.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+10
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
@@ -35,6 +36,7 @@ export class WorkspaceMigrationRunnerService {
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
private getLegacyCacheInvalidationPromises({
|
||||
@@ -168,6 +170,14 @@ export class WorkspaceMigrationRunnerService {
|
||||
metadataEvents: MetadataEvent[];
|
||||
hasSchemaMetadataChanged: boolean;
|
||||
}> => {
|
||||
if (this.twentyConfigService.get('WORKSPACE_SCHEMA_DDL_LOCKED')) {
|
||||
throw new WorkspaceMigrationRunnerException({
|
||||
message:
|
||||
'Workspace schema DDL changes are locked. This is typically set during hot upgrades.',
|
||||
code: WorkspaceMigrationRunnerExceptionCode.DDL_LOCKED,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.time('Runner', 'Total execution');
|
||||
this.logger.time('Runner', 'Initial cache retrieval');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user