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:
Charles Bochet
2026-04-02 12:17:04 +02:00
committed by GitHub
parent 9438b9869c
commit 81f10c586f
78 changed files with 2343 additions and 713 deletions
@@ -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);
}
}
@@ -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();