Files
twenty/packages/twenty-server/src/database/commands/run-instance-commands.command.ts
T
Paul Rastoinandprastoin e13c9ef46c [Upgrade] Fix workspace creation cursor (#19701)
The upgrade migration system required new workspaces to always start
from a workspace command, which was too rigid. When the system was
mid-upgrade within an instance command (IC) segment, workspace creation
would fail or produce inconsistent state.

Instance commands now write upgrade migration rows for **all
active/suspended workspaces** alongside the global row. This means every
workspace has a complete migration history, including instance command
records.

- `InstanceCommandRunnerService` reloads `activeOrSuspendedWorkspaceIds`
immediately before writing records (both success and failure paths) to
mitigate race conditions with concurrent workspace creation.
- `recordUpgradeMigration` in `UpgradeMigrationService` accepts a
discriminated union over `status`, handles `error: unknown` formatting
internally, and writes global + workspace rows in batch.

`getInitialCursorForNewWorkspace` now accepts the last **attempted**
(not just completed) instance command with its status:

- If the IC is `completed` and the next step is a workspace segment →
cursor is set to the last WC of that segment (existing behavior).
- If the IC is `failed` or not the last of its segment → cursor is set
to that IC itself, preserving its status.

This allows workspaces to be created at any point during the upgrade
lifecycle, including mid-IC-segment and after IC failure.

`validateWorkspaceCursorsAreInWorkspaceSegment` accepts workspaces whose
cursor is:
1. Within the current workspace segment, OR
2. At the immediately preceding instance command with `completed` status
(handles the `-w` single-workspace upgrade scenario).

Workspaces with cursors in a previous segment, ahead of the current
segment, or at a preceding IC with `failed` status are rejected.

created empty workspaces to allow testing upgrade with several active
workspaces
2026-04-15 17:51:35 +02:00

173 lines
5.3 KiB
TypeScript

import { Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command, CommandRunner, Option } from 'nest-commander';
import { DataSource } from 'typeorm';
import { TWENTY_PREVIOUS_VERSIONS } from 'src/engine/core-modules/upgrade/constants/twenty-previous-versions.constant';
import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service';
import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/services/upgrade-command-registry.service';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
type RunInstanceCommandsOptions = {
force?: boolean;
includeSlow?: boolean;
};
// TODO should be replaced by a specific call to the upgrade
@Command({
name: 'run-instance-commands',
description:
'Run legacy TypeORM migrations and all registered instance commands',
})
export class RunInstanceCommandsCommand extends CommandRunner {
private readonly logger = new Logger(RunInstanceCommandsCommand.name);
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly workspaceVersionService: WorkspaceVersionService,
private readonly upgradeCommandRegistryService: UpgradeCommandRegistryService,
private readonly instanceUpgradeService: InstanceCommandRunnerService,
private readonly upgradeMigrationService: UpgradeMigrationService,
) {
super();
}
@Option({
flags: '-f, --force',
description: 'Skip workspace version safety check',
required: false,
})
parseForce(): boolean {
return true;
}
@Option({
flags: '--include-slow',
description: 'Also run slow instance commands (data migration + DDL)',
required: false,
})
parseIncludeSlow(): boolean {
return true;
}
async run(
_passedParams: string[],
options: RunInstanceCommandsOptions,
): Promise<void> {
try {
await this.checkWorkspaceVersionSafety(options);
await this.runLegacyPendingTypeOrmMigrations();
const activeOrSuspendedWorkspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
for (const {
command,
name,
} of this.upgradeCommandRegistryService.getCrossUpgradeSupportedFastInstanceCommands()) {
const result = await this.instanceUpgradeService.runFastInstanceCommand(
{
command,
name,
},
);
if (result.status === 'failed') {
throw result.error;
}
}
if (options.includeSlow) {
for (const {
command,
name,
} of this.upgradeCommandRegistryService.getCrossUpgradeSupportedSlowInstanceCommands()) {
const result =
await this.instanceUpgradeService.runSlowInstanceCommand({
command,
name,
skipDataMigration: activeOrSuspendedWorkspaceIds.length === 0,
});
if (result.status === 'failed') {
throw result.error;
}
}
}
this.logger.log(chalk.green('Instance commands completed'));
} catch (error) {
this.logger.error(
chalk.red(`Instance commands failed: ${error.message}`),
);
throw error;
}
}
private async checkWorkspaceVersionSafety(
options: RunInstanceCommandsOptions,
): Promise<void> {
if (options.force) {
this.logger.warn(
chalk.yellow('Skipping workspace version check (--force flag used)'),
);
return;
}
const activeOrSuspendedWorkspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
if (activeOrSuspendedWorkspaceIds.length === 0) {
return;
}
const previousVersion =
TWENTY_PREVIOUS_VERSIONS[TWENTY_PREVIOUS_VERSIONS.length - 1];
const lastWorkspaceCommand =
this.upgradeCommandRegistryService.getLastWorkspaceCommandForVersion(
previousVersion,
);
if (!lastWorkspaceCommand) {
return;
}
const allAtPreviousVersion =
await this.upgradeMigrationService.areAllWorkspacesAtCommand({
commandName: lastWorkspaceCommand.name,
workspaceIds: activeOrSuspendedWorkspaceIds,
});
if (!allAtPreviousVersion) {
throw new Error(
'Unable to run instance commands. Some workspace(s) have not completed ' +
`the last workspace command for ${previousVersion} ("${lastWorkspaceCommand.name}").\n` +
'Please ensure all workspaces are upgraded to at least the previous version before running migrations.\n' +
'Use --force to bypass this check (not recommended).',
);
}
}
private async runLegacyPendingTypeOrmMigrations(): Promise<void> {
this.logger.log('Running legacy TypeORM migrations...');
const migrations = await this.dataSource.runMigrations({
transaction: 'each',
});
if (migrations.length === 0) {
this.logger.log('No pending legacy migrations');
} else {
this.logger.log(
`Executed ${migrations.length} legacy migration(s): ${migrations.map((migration) => migration.name).join(', ')}`,
);
}
}
}