upgrade:status command (#19584)
## Introduction Introducing a new command in order to determine the curent twenty instance and workspaces status as it's not stored in database but a derivation of each current curors ## `upgrade:status` all healthy <img width="1376" height="1202" alt="image" src="https://github.com/user-attachments/assets/e90d6987-07d2-4b6b-b573-105249aca325" /> ## `upgrade:status` Nearly use cases <img width="1442" height="1304" alt="image" src="https://github.com/user-attachments/assets/c336cb9d-eb9d-4c7d-9392-ec1ef54a7326" /> ## `upgrade:status --failed-only` <img width="1442" height="940" alt="image" src="https://github.com/user-attachments/assets/93a3dfdb-0d2f-4a01-b185-118e5cf0a078" /> ## `upgrade:status -w aa8fdcb1-8ee1-4012-98af-44a97caa7411 -w 20202020-1c25-4d02-bf25-6aeccf7ea419 -w 20202020-1c25-4d02-bf25-6aeccf7ea412` <img width="1486" height="928" alt="image" src="https://github.com/user-attachments/assets/ec1b1abc-46e8-4e36-9799-ab3a4b85e410" />
This commit is contained in:
@@ -24,6 +24,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { UpgradeStatusCommand } from 'src/engine/core-modules/upgrade/commands/upgrade-status.command';
|
||||
import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
@@ -86,6 +87,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
|
||||
EnterpriseKeyValidationCronCommand,
|
||||
GenerateApiKeyCommand,
|
||||
EnforceUsageCapCronCommand,
|
||||
UpgradeStatusCommand,
|
||||
],
|
||||
})
|
||||
export class DatabaseCommandModule {}
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
type MigrationCursorStatus,
|
||||
UpgradeHealth,
|
||||
UpgradeStatusService,
|
||||
type WorkspaceStatus,
|
||||
} from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
|
||||
|
||||
type UpgradeStatusOptions = {
|
||||
workspaceId?: Set<string>;
|
||||
failedOnly?: boolean;
|
||||
};
|
||||
|
||||
type GroupedWorkspaceStatuses = {
|
||||
upToDate: WorkspaceStatus[];
|
||||
behind: WorkspaceStatus[];
|
||||
failed: WorkspaceStatus[];
|
||||
};
|
||||
|
||||
const HEALTH_LABELS: Record<UpgradeHealth, string> = {
|
||||
'up-to-date': chalk.green('Up to date'),
|
||||
behind: chalk.yellow('Behind'),
|
||||
failed: chalk.red('Failed'),
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:status',
|
||||
description:
|
||||
'Display upgrade status for instance and workspace commands, inferring versions from migration history',
|
||||
})
|
||||
export class UpgradeStatusCommand extends CommandRunner {
|
||||
@Option({
|
||||
flags: '-w, --workspace-id [workspace_id]',
|
||||
description:
|
||||
'Filter to specific workspace IDs. Can be passed multiple times.',
|
||||
required: false,
|
||||
})
|
||||
parseWorkspaceId(value: string, previous?: Set<string>): Set<string> {
|
||||
const accumulator = previous ?? new Set<string>();
|
||||
|
||||
accumulator.add(value);
|
||||
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-f, --failed-only',
|
||||
description:
|
||||
'Hide up-to-date entries, only display failed and behind commands',
|
||||
})
|
||||
parseFailedOnly(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
private readonly logger = new Logger(UpgradeStatusCommand.name);
|
||||
|
||||
constructor(
|
||||
private readonly upgradeStatusService: UpgradeStatusService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(
|
||||
_passedParams: string[],
|
||||
options: UpgradeStatusOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const lines: string[] = this.formatHeader();
|
||||
|
||||
const instanceStatus =
|
||||
await this.upgradeStatusService.getInstanceStatus();
|
||||
|
||||
lines.push(...this.formatInstanceStatus(instanceStatus));
|
||||
|
||||
const requestedWorkspaceIds = options.workspaceId
|
||||
? [...options.workspaceId]
|
||||
: undefined;
|
||||
|
||||
const workspaceStatuses =
|
||||
await this.upgradeStatusService.getWorkspaceStatuses(
|
||||
requestedWorkspaceIds,
|
||||
);
|
||||
|
||||
const groupedWorkspaceStatuses =
|
||||
this.groupWorkspaceStatusesByHealth(workspaceStatuses);
|
||||
|
||||
lines.push(
|
||||
...this.formatWorkspaceStatuses(
|
||||
groupedWorkspaceStatuses,
|
||||
options.failedOnly,
|
||||
),
|
||||
);
|
||||
|
||||
lines.push(
|
||||
...this.formatSummary(instanceStatus, groupedWorkspaceStatuses),
|
||||
);
|
||||
|
||||
console.log(lines.join('\n'));
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
chalk.red(`Failed to retrieve upgrade status: ${error.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private formatHeader(): string[] {
|
||||
const appVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
|
||||
|
||||
return ['', chalk.bold(`APP_VERSION: ${appVersion}`), ''];
|
||||
}
|
||||
|
||||
private formatInstanceStatus(status: MigrationCursorStatus): string[] {
|
||||
return [
|
||||
chalk.bold.underline('Instance'),
|
||||
...this.formatCursorStatus(status),
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
private formatWorkspaceStatuses(
|
||||
{ upToDate, behind, failed }: GroupedWorkspaceStatuses,
|
||||
failedOnly?: boolean,
|
||||
): string[] {
|
||||
const lines: string[] = [chalk.bold.underline('Workspace')];
|
||||
|
||||
if (upToDate.length === 0 && behind.length === 0 && failed.length === 0) {
|
||||
lines.push(chalk.dim(' No active/suspended workspaces found'));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (!failedOnly) {
|
||||
for (const workspaceStatus of upToDate) {
|
||||
lines.push(...this.formatWorkspaceStatus(workspaceStatus));
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspaceStatus of behind) {
|
||||
lines.push(...this.formatWorkspaceStatus(workspaceStatus));
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
const groupedByCommand = new Map<string, WorkspaceStatus[]>();
|
||||
|
||||
for (const workspaceStatus of failed) {
|
||||
const commandName = workspaceStatus.latestCommand?.name ?? 'unknown';
|
||||
|
||||
if (!groupedByCommand.has(commandName)) {
|
||||
groupedByCommand.set(commandName, []);
|
||||
}
|
||||
|
||||
groupedByCommand.get(commandName)!.push(workspaceStatus);
|
||||
}
|
||||
|
||||
for (const [commandName, statuses] of groupedByCommand) {
|
||||
lines.push(chalk.red.bold(` Failed at: ${commandName}`));
|
||||
|
||||
for (const workspaceStatus of statuses) {
|
||||
lines.push(...this.formatWorkspaceStatus(workspaceStatus, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private formatWorkspaceStatus(
|
||||
status: WorkspaceStatus,
|
||||
nested = false,
|
||||
): string[] {
|
||||
const baseIndent = nested ? ' ' : ' ';
|
||||
const detailIndent = nested ? ' ' : ' ';
|
||||
const label = status.displayName
|
||||
? `${status.displayName} (${status.workspaceId})`
|
||||
: status.workspaceId;
|
||||
|
||||
return [
|
||||
chalk.bold(`${baseIndent}${label}`),
|
||||
...this.formatCursorStatus(status, detailIndent),
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
private formatCursorStatus(
|
||||
status: MigrationCursorStatus,
|
||||
indent = ' ',
|
||||
): string[] {
|
||||
if (!status.latestCommand) {
|
||||
return [`${indent}Status: ${HEALTH_LABELS[status.health]}`];
|
||||
}
|
||||
|
||||
const { latestCommand } = status;
|
||||
|
||||
const lines: string[] = [
|
||||
`${indent}Inferred version: ${status.inferredVersion ?? chalk.dim('unknown')}`,
|
||||
`${indent}Latest command: ${latestCommand.name}`,
|
||||
`${indent}Status: ${HEALTH_LABELS[status.health]}`,
|
||||
`${indent}Executed by: ${latestCommand.executedByVersion}`,
|
||||
`${indent}At: ${latestCommand.createdAt.toISOString()}`,
|
||||
];
|
||||
|
||||
if (latestCommand.status === 'failed' && latestCommand.errorMessage) {
|
||||
lines.push(
|
||||
chalk.red(`${indent}Error: ${latestCommand.errorMessage}`),
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private formatSummary(
|
||||
instanceStatus: MigrationCursorStatus,
|
||||
{ upToDate, behind, failed }: GroupedWorkspaceStatuses,
|
||||
): string[] {
|
||||
const lines: string[] = [chalk.bold.underline('Summary')];
|
||||
const totalCount = upToDate.length + behind.length + failed.length;
|
||||
|
||||
lines.push(` Instance: ${HEALTH_LABELS[instanceStatus.health]}`);
|
||||
|
||||
if (totalCount === 0) {
|
||||
lines.push(chalk.dim(' No workspaces'));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
const parts = [
|
||||
chalk.green(`${upToDate.length} up to date`),
|
||||
chalk.yellow(`${behind.length} behind`),
|
||||
chalk.red(`${failed.length} failed`),
|
||||
];
|
||||
|
||||
lines.push(` Workspaces: ${parts.join(', ')} (${totalCount} total)`);
|
||||
|
||||
if (behind.length > 0) {
|
||||
const behindCounts = new Map<string, number>();
|
||||
|
||||
for (const status of behind) {
|
||||
const commandName = status.latestCommand?.name ?? 'no commands';
|
||||
|
||||
behindCounts.set(commandName, (behindCounts.get(commandName) ?? 0) + 1);
|
||||
}
|
||||
|
||||
for (const [commandName, count] of behindCounts) {
|
||||
lines.push(chalk.yellow(` ${count} behind at: ${commandName}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
const failureCounts = new Map<string, number>();
|
||||
|
||||
for (const status of failed) {
|
||||
const commandName = status.latestCommand?.name ?? 'unknown';
|
||||
|
||||
failureCounts.set(
|
||||
commandName,
|
||||
(failureCounts.get(commandName) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [commandName, count] of failureCounts) {
|
||||
lines.push(chalk.red(` ${count} failed at: ${commandName}`));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private groupWorkspaceStatusesByHealth(
|
||||
workspaceStatuses: WorkspaceStatus[],
|
||||
): GroupedWorkspaceStatuses {
|
||||
const upToDate: WorkspaceStatus[] = [];
|
||||
const behind: WorkspaceStatus[] = [];
|
||||
const failed: WorkspaceStatus[] = [];
|
||||
|
||||
for (const status of workspaceStatuses) {
|
||||
switch (status.health) {
|
||||
case 'up-to-date':
|
||||
upToDate.push(status);
|
||||
break;
|
||||
case 'behind':
|
||||
behind.push(status);
|
||||
break;
|
||||
case 'failed':
|
||||
failed.push(status);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { upToDate, behind, failed };
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
||||
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
|
||||
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
const LAST_INSTANCE_COMMAND = '1.23.0_LastInstanceCommand_1780000002000';
|
||||
const LAST_WORKSPACE_COMMAND = '1.23.0_LastWorkspaceCommand_1780000003000';
|
||||
const EARLIER_COMMAND = '1.22.0_EarlierCommand_1776000001000';
|
||||
|
||||
const MOCK_SEQUENCE = [
|
||||
{ kind: 'fast-instance', name: EARLIER_COMMAND },
|
||||
{ kind: 'fast-instance', name: LAST_INSTANCE_COMMAND },
|
||||
{ kind: 'workspace', name: LAST_WORKSPACE_COMMAND },
|
||||
];
|
||||
|
||||
describe('UpgradeStatusService', () => {
|
||||
let service: UpgradeStatusService;
|
||||
let getLastAttemptedInstanceCommand: jest.Mock;
|
||||
let getWorkspaceLastAttemptedCommandName: jest.Mock;
|
||||
let workspaceFind: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
getLastAttemptedInstanceCommand = jest.fn();
|
||||
getWorkspaceLastAttemptedCommandName = jest.fn();
|
||||
workspaceFind = jest.fn();
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
UpgradeStatusService,
|
||||
{
|
||||
provide: UpgradeMigrationService,
|
||||
useValue: {
|
||||
getLastAttemptedInstanceCommand,
|
||||
getWorkspaceLastAttemptedCommandName,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UpgradeSequenceReaderService,
|
||||
useValue: {
|
||||
getUpgradeSequence: () => MOCK_SEQUENCE,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(WorkspaceEntity),
|
||||
useValue: { find: workspaceFind },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(UpgradeStatusService);
|
||||
});
|
||||
|
||||
describe('getInstanceStatus', () => {
|
||||
it('should return up-to-date when cursor is at last instance command', async () => {
|
||||
getLastAttemptedInstanceCommand.mockResolvedValue({
|
||||
name: LAST_INSTANCE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: null,
|
||||
createdAt: new Date('2025-06-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await service.getInstanceStatus();
|
||||
|
||||
expect(result.health).toBe('up-to-date');
|
||||
expect(result.inferredVersion).toBe('1.23.0');
|
||||
});
|
||||
|
||||
it('should return behind when cursor is before last instance command', async () => {
|
||||
getLastAttemptedInstanceCommand.mockResolvedValue({
|
||||
name: EARLIER_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.22.0',
|
||||
errorMessage: null,
|
||||
createdAt: new Date('2025-06-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await service.getInstanceStatus();
|
||||
|
||||
expect(result.health).toBe('behind');
|
||||
expect(result.inferredVersion).toBe('1.22.0');
|
||||
});
|
||||
|
||||
it('should return failed when latest instance command failed', async () => {
|
||||
getLastAttemptedInstanceCommand.mockResolvedValue({
|
||||
name: LAST_INSTANCE_COMMAND,
|
||||
status: 'failed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: 'column does not exist',
|
||||
createdAt: new Date('2025-06-01T01:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await service.getInstanceStatus();
|
||||
|
||||
expect(result.health).toBe('failed');
|
||||
expect(result.latestCommand?.errorMessage).toBe('column does not exist');
|
||||
});
|
||||
|
||||
it('should return behind when no migrations exist', async () => {
|
||||
getLastAttemptedInstanceCommand.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getInstanceStatus();
|
||||
|
||||
expect(result.health).toBe('behind');
|
||||
expect(result.inferredVersion).toBeNull();
|
||||
expect(result.latestCommand).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkspaceStatuses', () => {
|
||||
it('should return up-to-date for workspace at last command', async () => {
|
||||
workspaceFind.mockResolvedValue([{ id: 'ws-1', displayName: 'Apple' }]);
|
||||
|
||||
getWorkspaceLastAttemptedCommandName.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'ws-1',
|
||||
{
|
||||
workspaceId: 'ws-1',
|
||||
name: LAST_WORKSPACE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: null,
|
||||
createdAt: new Date('2025-06-01T00:00:00Z'),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const results = await service.getWorkspaceStatuses();
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].health).toBe('up-to-date');
|
||||
});
|
||||
|
||||
it('should return behind for workspace not at last command', async () => {
|
||||
workspaceFind.mockResolvedValue([
|
||||
{ id: 'ws-1', displayName: 'Apple' },
|
||||
{ id: 'ws-2', displayName: 'Google' },
|
||||
]);
|
||||
|
||||
getWorkspaceLastAttemptedCommandName.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'ws-1',
|
||||
{
|
||||
workspaceId: 'ws-1',
|
||||
name: LAST_WORKSPACE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: null,
|
||||
createdAt: new Date('2025-06-01T00:00:00Z'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'ws-2',
|
||||
{
|
||||
workspaceId: 'ws-2',
|
||||
name: EARLIER_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.22.0',
|
||||
errorMessage: null,
|
||||
createdAt: new Date('2025-05-01T00:00:00Z'),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const results = await service.getWorkspaceStatuses();
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].health).toBe('up-to-date');
|
||||
expect(results[1].health).toBe('behind');
|
||||
});
|
||||
|
||||
it('should return behind for workspace with no migration history', async () => {
|
||||
workspaceFind.mockResolvedValue([{ id: 'ws-1', displayName: 'Apple' }]);
|
||||
|
||||
getWorkspaceLastAttemptedCommandName.mockResolvedValue(new Map());
|
||||
|
||||
const results = await service.getWorkspaceStatuses();
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].health).toBe('behind');
|
||||
expect(results[0].latestCommand).toBeNull();
|
||||
});
|
||||
|
||||
it('should return empty array when no workspaces exist', async () => {
|
||||
workspaceFind.mockResolvedValue([]);
|
||||
getWorkspaceLastAttemptedCommandName.mockResolvedValue(new Map());
|
||||
|
||||
const results = await service.getWorkspaceStatuses();
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+58
-22
@@ -10,9 +10,13 @@ import {
|
||||
} from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
|
||||
import { formatUpgradeErrorForStorage } from 'src/engine/core-modules/upgrade/utils/format-upgrade-error-for-storage.util';
|
||||
|
||||
export type WorkspaceCursor = {
|
||||
export type WorkspaceLastAttemptedCommand = {
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
status: UpgradeMigrationStatus;
|
||||
executedByVersion: string;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
isInitial: boolean;
|
||||
};
|
||||
|
||||
@@ -195,19 +199,24 @@ export class UpgradeMigrationService {
|
||||
return { name: migration.name, status: migration.status };
|
||||
}
|
||||
|
||||
async getWorkspaceLastAttemptedCommandNameOrThrow(
|
||||
async getWorkspaceLastAttemptedCommandName(
|
||||
workspaceIds: string[],
|
||||
): Promise<Map<string, WorkspaceCursor>> {
|
||||
): Promise<Map<string, WorkspaceLastAttemptedCommand>> {
|
||||
if (workspaceIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const results = await this.upgradeMigrationRepository
|
||||
const migrations = await this.upgradeMigrationRepository
|
||||
.createQueryBuilder('migration')
|
||||
.select('migration.workspaceId', 'workspaceId')
|
||||
.addSelect('migration.name', 'name')
|
||||
.addSelect('migration.status', 'status')
|
||||
.addSelect('migration.isInitial', 'isInitial')
|
||||
.select([
|
||||
'migration.workspaceId',
|
||||
'migration.name',
|
||||
'migration.status',
|
||||
'migration.executedByVersion',
|
||||
'migration.errorMessage',
|
||||
'migration.createdAt',
|
||||
'migration.isInitial',
|
||||
])
|
||||
.where({
|
||||
workspaceId: In(workspaceIds),
|
||||
})
|
||||
@@ -222,23 +231,35 @@ export class UpgradeMigrationService {
|
||||
.orderBy('migration.workspaceId')
|
||||
.addOrderBy('migration.createdAt', 'DESC')
|
||||
.distinctOn(['migration.workspaceId'])
|
||||
.getRawMany<{
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
status: UpgradeMigrationStatus;
|
||||
isInitial: boolean;
|
||||
}>();
|
||||
.getMany();
|
||||
|
||||
const cursors = new Map<string, WorkspaceCursor>();
|
||||
const cursors = new Map<string, WorkspaceLastAttemptedCommand>();
|
||||
|
||||
for (const row of results) {
|
||||
cursors.set(row.workspaceId, {
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
isInitial: row.isInitial,
|
||||
for (const migration of migrations) {
|
||||
if (migration.workspaceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cursors.set(migration.workspaceId, {
|
||||
workspaceId: migration.workspaceId,
|
||||
name: migration.name,
|
||||
status: migration.status,
|
||||
executedByVersion: migration.executedByVersion,
|
||||
errorMessage: migration.errorMessage,
|
||||
createdAt: migration.createdAt,
|
||||
isInitial: migration.isInitial,
|
||||
});
|
||||
}
|
||||
|
||||
return cursors;
|
||||
}
|
||||
|
||||
async getWorkspaceLastAttemptedCommandNameOrThrow(
|
||||
workspaceIds: string[],
|
||||
): Promise<Map<string, WorkspaceLastAttemptedCommand>> {
|
||||
const cursors =
|
||||
await this.getWorkspaceLastAttemptedCommandName(workspaceIds);
|
||||
|
||||
const missingWorkspaceIds = workspaceIds.filter(
|
||||
(workspaceId) => !cursors.has(workspaceId),
|
||||
);
|
||||
@@ -286,10 +307,19 @@ export class UpgradeMigrationService {
|
||||
async getLastAttemptedInstanceCommand(): Promise<{
|
||||
name: string;
|
||||
status: UpgradeMigrationStatus;
|
||||
executedByVersion: string;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
} | null> {
|
||||
const migration = await this.upgradeMigrationRepository
|
||||
.createQueryBuilder('migration')
|
||||
.select(['migration.name', 'migration.status'])
|
||||
.select([
|
||||
'migration.name',
|
||||
'migration.status',
|
||||
'migration.executedByVersion',
|
||||
'migration.errorMessage',
|
||||
'migration.createdAt',
|
||||
])
|
||||
.where('migration."workspaceId" IS NULL')
|
||||
.andWhere('migration."isInitial" = false')
|
||||
.andWhere(
|
||||
@@ -307,7 +337,13 @@ export class UpgradeMigrationService {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name: migration.name, status: migration.status };
|
||||
return {
|
||||
name: migration.name,
|
||||
status: migration.status,
|
||||
executedByVersion: migration.executedByVersion,
|
||||
errorMessage: migration.errorMessage,
|
||||
createdAt: migration.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getLastAttemptedInstanceCommandOrThrow(): Promise<{
|
||||
|
||||
+4
-4
@@ -7,8 +7,8 @@ import {
|
||||
import { type ParsedUpgradeCommandOptions } from 'src/database/commands/upgrade-version-command/upgrade.command';
|
||||
import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service';
|
||||
import {
|
||||
type WorkspaceCursor,
|
||||
UpgradeMigrationService,
|
||||
WorkspaceLastAttemptedCommand,
|
||||
} from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
||||
import {
|
||||
type InstanceUpgradeStep,
|
||||
@@ -233,7 +233,7 @@ export class UpgradeSequenceRunnerService {
|
||||
|
||||
private async fetchWorkspaceCursors(
|
||||
allActiveOrSuspendedWorkspaceIds: string[],
|
||||
): Promise<Map<string, WorkspaceCursor>> {
|
||||
): Promise<Map<string, WorkspaceLastAttemptedCommand>> {
|
||||
return this.upgradeMigrationService.getWorkspaceLastAttemptedCommandNameOrThrow(
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
);
|
||||
@@ -286,7 +286,7 @@ export class UpgradeSequenceRunnerService {
|
||||
options,
|
||||
}: {
|
||||
workspaceCommandsSegment: WorkspaceUpgradeStep[];
|
||||
workspaceCursors: Map<string, WorkspaceCursor>;
|
||||
workspaceCursors: Map<string, WorkspaceLastAttemptedCommand>;
|
||||
allActiveOrSuspendedWorkspaceIds: string[];
|
||||
options: ParsedUpgradeCommandOptions;
|
||||
}): Promise<WorkspaceIteratorReport> {
|
||||
@@ -331,7 +331,7 @@ export class UpgradeSequenceRunnerService {
|
||||
}: {
|
||||
sequence: UpgradeStep[];
|
||||
previousWorkspaceStep: WorkspaceUpgradeStep;
|
||||
workspaceCursors: Map<string, WorkspaceCursor>;
|
||||
workspaceCursors: Map<string, WorkspaceLastAttemptedCommand>;
|
||||
}): void {
|
||||
const barrierCursor =
|
||||
this.upgradeSequenceReaderService.locateStepInSequenceOrThrow({
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
||||
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
|
||||
import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
|
||||
import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export type UpgradeHealth = 'up-to-date' | 'behind' | 'failed';
|
||||
|
||||
export type MigrationCursorStatus = {
|
||||
inferredVersion: string | null;
|
||||
health: UpgradeHealth;
|
||||
latestCommand: {
|
||||
name: string;
|
||||
status: UpgradeMigrationStatus;
|
||||
executedByVersion: string;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type WorkspaceStatus = MigrationCursorStatus & {
|
||||
workspaceId: string;
|
||||
displayName: string | null;
|
||||
};
|
||||
|
||||
const deriveHealth = (
|
||||
migration: { name: string; status: UpgradeMigrationStatus },
|
||||
lastExpectedCommandName: string | null,
|
||||
): UpgradeHealth => {
|
||||
if (migration.status === 'failed') {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
if (
|
||||
lastExpectedCommandName !== null &&
|
||||
migration.name !== lastExpectedCommandName
|
||||
) {
|
||||
return 'behind';
|
||||
}
|
||||
|
||||
return 'up-to-date';
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UpgradeStatusService {
|
||||
private readonly logger = new Logger(UpgradeStatusService.name);
|
||||
|
||||
constructor(
|
||||
private readonly upgradeMigrationService: UpgradeMigrationService,
|
||||
private readonly upgradeSequenceReaderService: UpgradeSequenceReaderService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
async getInstanceStatus(): Promise<MigrationCursorStatus> {
|
||||
const migration =
|
||||
await this.upgradeMigrationService.getLastAttemptedInstanceCommand();
|
||||
|
||||
const sequence = this.upgradeSequenceReaderService.getUpgradeSequence();
|
||||
const lastInstanceStep = [...sequence]
|
||||
.reverse()
|
||||
.find(
|
||||
(step) =>
|
||||
step.kind === 'fast-instance' || step.kind === 'slow-instance',
|
||||
);
|
||||
|
||||
return this.buildCursorStatus(migration, lastInstanceStep?.name ?? null);
|
||||
}
|
||||
|
||||
async getWorkspaceStatuses(
|
||||
filterWorkspaceIds?: string[],
|
||||
): Promise<WorkspaceStatus[]> {
|
||||
const workspaces = await this.loadWorkspaces(filterWorkspaceIds);
|
||||
|
||||
if (filterWorkspaceIds) {
|
||||
const foundIds = new Set(workspaces.map((workspace) => workspace.id));
|
||||
|
||||
for (const requestedId of filterWorkspaceIds) {
|
||||
if (!foundIds.has(requestedId)) {
|
||||
this.logger.warn(
|
||||
`Workspace ${requestedId} not found or not active/suspended`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadedWorkspaceIds = workspaces.map((workspace) => workspace.id);
|
||||
const cursors =
|
||||
await this.upgradeMigrationService.getWorkspaceLastAttemptedCommandName(
|
||||
loadedWorkspaceIds,
|
||||
);
|
||||
|
||||
const sequence = this.upgradeSequenceReaderService.getUpgradeSequence();
|
||||
const lastStepName =
|
||||
sequence.length > 0 ? sequence[sequence.length - 1].name : null;
|
||||
|
||||
return workspaces.map((workspace) => ({
|
||||
...this.buildCursorStatus(
|
||||
cursors.get(workspace.id) ?? null,
|
||||
lastStepName,
|
||||
),
|
||||
workspaceId: workspace.id,
|
||||
displayName: workspace.displayName ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
private buildCursorStatus(
|
||||
migration: {
|
||||
name: string;
|
||||
status: UpgradeMigrationStatus;
|
||||
executedByVersion: string;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
} | null,
|
||||
lastExpectedCommandName: string | null,
|
||||
): MigrationCursorStatus {
|
||||
if (!migration) {
|
||||
return { inferredVersion: null, health: 'behind', latestCommand: null };
|
||||
}
|
||||
|
||||
const health = deriveHealth(migration, lastExpectedCommandName);
|
||||
|
||||
return {
|
||||
inferredVersion: extractVersionFromCommandName(migration.name),
|
||||
health,
|
||||
latestCommand: {
|
||||
name: migration.name,
|
||||
status: migration.status,
|
||||
executedByVersion: migration.executedByVersion,
|
||||
errorMessage: migration.errorMessage,
|
||||
createdAt: migration.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadWorkspaces(
|
||||
workspaceIds?: string[],
|
||||
): Promise<Pick<WorkspaceEntity, 'id' | 'displayName'>[]> {
|
||||
return this.workspaceRepository.find({
|
||||
select: ['id', 'displayName'],
|
||||
where: {
|
||||
...(workspaceIds && workspaceIds.length > 0
|
||||
? { id: In(workspaceIds) }
|
||||
: {}),
|
||||
activationStatus: In([
|
||||
WorkspaceActivationStatus.ACTIVE,
|
||||
WorkspaceActivationStatus.SUSPENDED,
|
||||
]),
|
||||
},
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/s
|
||||
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
||||
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
|
||||
import { UpgradeSequenceRunnerService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service';
|
||||
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
|
||||
import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/workspace-command-runner.service';
|
||||
import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -31,6 +32,7 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v
|
||||
UpgradeCommandRegistryService,
|
||||
UpgradeSequenceReaderService,
|
||||
UpgradeSequenceRunnerService,
|
||||
UpgradeStatusService,
|
||||
],
|
||||
exports: [
|
||||
UpgradeMigrationService,
|
||||
@@ -39,6 +41,7 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v
|
||||
UpgradeCommandRegistryService,
|
||||
UpgradeSequenceReaderService,
|
||||
UpgradeSequenceRunnerService,
|
||||
UpgradeStatusService,
|
||||
],
|
||||
})
|
||||
export class UpgradeModule {}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util';
|
||||
|
||||
describe('extractVersionFromCommandName', () => {
|
||||
it('should extract version from standard command name', () => {
|
||||
expect(
|
||||
extractVersionFromCommandName(
|
||||
'1.21.0_BackfillDatasourceCommand_1775500003000',
|
||||
),
|
||||
).toBe('1.21.0');
|
||||
});
|
||||
|
||||
it('should extract version with different version numbers', () => {
|
||||
expect(
|
||||
extractVersionFromCommandName('1.22.0_SomeCommand_1780000001000'),
|
||||
).toBe('1.22.0');
|
||||
});
|
||||
|
||||
it('should return null for names without underscores', () => {
|
||||
expect(extractVersionFromCommandName('nounderscores')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(extractVersionFromCommandName('')).toBeNull();
|
||||
});
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export const extractVersionFromCommandName = (name: string): string | null => {
|
||||
const firstUnderscore = name.indexOf('_');
|
||||
|
||||
if (firstUnderscore === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return name.substring(0, firstUnderscore);
|
||||
};
|
||||
Reference in New Issue
Block a user