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
@@ -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')
@@ -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',
},
);
});
});
@@ -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(