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:
@@ -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')
|
||||
|
||||
+95
@@ -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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+46
-23
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user