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:
+96
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+45
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
@@ -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`,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user