81f10c586f
## 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.
97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|