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,96 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { DataSource, type Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { addGlobalKeyValuePairUniqueIndexQueries } from 'src/database/typeorm/core/migrations/utils/1774700000000-add-global-key-value-pair-unique-index.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:1-21:add-global-key-value-pair-unique-index',
description:
'Deduplicate global keyValuePair rows and add the null/null unique index',
})
export class AddGlobalKeyValuePairUniqueIndexCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
private hasRunOnce = false;
private async deduplicateGlobalKeyValuePairs(
queryRunner: DataSource['createQueryRunner'] extends () => infer T
? T
: never,
): Promise<void> {
await queryRunner.query(`
DELETE FROM "core"."keyValuePair"
WHERE id IN (
SELECT id
FROM (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY key
ORDER BY "updatedAt" DESC, "createdAt" DESC, id DESC
) AS row_number
FROM "core"."keyValuePair"
WHERE "userId" IS NULL
AND "workspaceId" IS NULL
) ranked_key_value_pairs
WHERE ranked_key_value_pairs.row_number > 1
)
`);
}
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (this.hasRunOnce) {
this.logger.log(
'Skipping has already been run once AddGlobalKeyValuePairUniqueIndexCommand',
);
return;
}
if (options.dryRun) {
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await this.deduplicateGlobalKeyValuePairs(queryRunner);
await addGlobalKeyValuePairUniqueIndexQueries(queryRunner);
await queryRunner.commitTransaction();
this.logger.log(
'Successfully run AddGlobalKeyValuePairUniqueIndexCommand',
);
this.hasRunOnce = true;
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Rolling back AddGlobalKeyValuePairUniqueIndexCommand: ${error.message}`,
);
throw error;
} finally {
await queryRunner.release();
}
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command';
@@ -24,6 +25,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FeatureFlagModule,
],
providers: [
AddGlobalKeyValuePairUniqueIndexCommand,
BackfillDatasourceToWorkspaceCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
DeduplicateEngineCommandsCommand,
@@ -31,6 +33,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
UpdateEditLayoutCommandMenuItemLabelCommand,
],
exports: [
AddGlobalKeyValuePairUniqueIndexCommand,
BackfillDatasourceToWorkspaceCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
DeduplicateEngineCommandsCommand,
@@ -12,6 +12,7 @@ import {
import { CoreMigrationRunnerService } from 'src/database/commands/core-migration-runner/services/core-migration-runner.service';
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command';
import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command';
import { IdentifyFieldPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command';
@@ -72,6 +73,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly makeWorkflowSearchableCommand: MakeWorkflowSearchableCommand,
// 1.21 Commands
protected readonly addGlobalKeyValuePairUniqueIndexCommand: AddGlobalKeyValuePairUniqueIndexCommand,
protected readonly backfillDatasourceToWorkspaceCommand: BackfillDatasourceToWorkspaceCommand,
protected readonly backfillPageLayoutsAndFieldsWidgetViewFieldsCommand: BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
protected readonly deduplicateEngineCommandsCommand: DeduplicateEngineCommandsCommand,
@@ -110,6 +112,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
];
const commands_1210: VersionCommands = [
this.addGlobalKeyValuePairUniqueIndexCommand,
this.backfillDatasourceToWorkspaceCommand,
this.backfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
this.deduplicateEngineCommandsCommand,
@@ -0,0 +1,45 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
import { addGlobalKeyValuePairUniqueIndexQueries } from 'src/database/typeorm/core/migrations/utils/1774700000000-add-global-key-value-pair-unique-index.util';
export class AddGlobalKeyValuePairUniqueIndex1774700000000
implements MigrationInterface
{
name = 'AddGlobalKeyValuePairUniqueIndex1774700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const savepointName = 'sp_add_global_key_value_pair_unique_index';
try {
await queryRunner.query(`SAVEPOINT ${savepointName}`);
await addGlobalKeyValuePairUniqueIndexQueries(queryRunner);
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
} catch (e) {
try {
await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
} catch (rollbackError) {
// oxlint-disable-next-line no-console
console.error(
'Failed to rollback to savepoint in AddGlobalKeyValuePairUniqueIndex1774700000000',
rollbackError,
);
throw rollbackError;
}
// oxlint-disable-next-line no-console
console.error(
'Swallowing AddGlobalKeyValuePairUniqueIndex1774700000000 error',
e,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "core"."IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE"`,
);
}
}
@@ -0,0 +1,13 @@
import { type QueryRunner } from 'typeorm';
export const addGlobalKeyValuePairUniqueIndexQueries = async (
queryRunner: QueryRunner,
): Promise<void> => {
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE" ON "core"."keyValuePair" ("key") WHERE "userId" is NULL AND "workspaceId" is NULL`,
);
};