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:
Paul Rastoin
2026-04-16 18:05:02 +02:00
committed by GitHub
parent aecbc89a3f
commit bf410ae438
9 changed files with 759 additions and 26 deletions
@@ -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 {}
@@ -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 };
}
}
@@ -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);
});
});
});
@@ -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<{
@@ -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({
@@ -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 {}
@@ -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();
});
});
@@ -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);
};