Files
twenty/packages/twenty-server/src/database/commands/generate-instance-command.command.ts
T
Paul Rastoin 21142d98fe Implement cross version upgrade (#19559)
# Introduction
Refactoring the upgrade engine to handle cross version upgrade,
completely getting rid of the semver `version` at db and runtime level
It remains a visual a listing indicator for or CD process but also
during devenv in order to prepare next release
Will write a release process runbook documentation on how to handle
upgrade step patch, command insertion etc as it needs to be cascaded
across all the involved supported version

**The upgrade sequence model:**

The sequence is a flat, ordered array of upgrade steps
(`UpgradeStep[]`), built from the registry by chaining all versions in
order, each version contributing its fast-instance → slow-instance →
workspace commands sorted by timestamp. Version is metadata for logging,
not used in the algorithm.

**Segments:**

The sequence naturally splits into alternating segments of contiguous
instance steps and contiguous workspace steps. The runner processes
segments in order:

- **Instance segment:** Run sequentially from the instance cursor. Each
step runs once globally.
- **Workspace segment:** Each workspace independently walks from its own
cursor through the end of the segment. Workspaces are independent within
a segment — they can be at different positions.
- **Synchronization (workspace → instance):** The runner blocks before
entering an instance segment. All active/suspended workspaces must have
completed the last workspace step of the preceding workspace segment. If
any workspace failed, abort. This is the only explicit synchronization
point.
- Instance → workspace ordering is implicit — the runner processes
segments sequentially, so the instance segment naturally completes
before the workspace segment begins.


full docs
https://gist.github.com/prastoin/e62106d455fd72d6b6ebada8351e5492

## Version constants & type-level deprecation

Version management is split into three atomic constants:
`TWENTY_PREVIOUS_VERSIONS`, `TWENTY_CURRENT_VERSION`, and
`TWENTY_NEXT_VERSIONS`. Two derived constants compose them:
`CROSS_UPGRADE_SUPPORTED_VERSIONS` (previous + current — what the engine
runs) and `ALL_TWENTY_VERSIONS` (the full ordered tuple including next).
The registry service validates at module init that no version is
duplicated across constants and that at least one previous version
exists.

A `DeprecatedSinceVersion<RemoveAtVersion, T>` type utility resolves to
`T` while `TWENTY_CURRENT_VERSION` is below `RemoveAtVersion`, and to
`never` once it reaches it — turning deprecation into a compile-time
guarantee via `IndexOf` and `IsGreaterOrEqual` generics in
`twenty-shared`.

### `workspace.version` column deprecation

The column is replaced by cursor-based state inference from
`UpgradeMigration` records, but cannot be dropped in 1.22: workspaces
activated during 1.21 predate the cursor system and need their initial
cursor backfilled first (`backfillWorkspaceCreatedIn1_21_0Cursors`).
This backfill itself depends on a new `isInitial` column on
`UpgradeMigration`, bootstrapped via a targeted TypeORM migration before
the upgrade sequence runs.

Both functions and the entity field are typed with
`DeprecatedSinceVersion<'1.23.0', ...>`. When `TWENTY_CURRENT_VERSION`
reaches `1.23.0`, compile errors force their removal — and the
pre-declared `DropWorkspaceVersionColumnFastInstanceCommand` takes over
to drop the column.

## What's next
- ci cross version upgrade ( wip )
- banner asking to contact twenty administrator if workspace is outdated
- upgrade healthcheck cli 

## New unit/integ test pattern
Create a dedicated `createNestApp` that consumes a real database in
order not to have to mack any database interaction to the
`upgradeMigrations` allowing full coverage of the whole
`upgradeRunnerService.run` core logic
2026-04-13 11:42:27 +02:00

164 lines
4.6 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service';
import {
TWENTY_ALL_VERSIONS,
type TwentyAllVersion,
} from 'src/engine/core-modules/upgrade/constants/twenty-all-versions.constant';
import { TWENTY_CURRENT_VERSION } from 'src/engine/core-modules/upgrade/constants/twenty-current-version.constant';
import { type InstanceCommandType } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
const UPGRADE_VERSION_COMMAND_DIR = path.resolve(
process.cwd(),
'src/database/commands/upgrade-version-command',
);
type GenerateInstanceCommandOptions = {
name: string;
type: InstanceCommandType;
version?: TwentyAllVersion;
};
@Command({
name: 'generate:instance-command',
description:
'Generate an instance command with @RegisteredInstanceCommand decorator for the latest supported version',
})
export class GenerateInstanceCommandCommand extends CommandRunner {
private readonly logger = new Logger(GenerateInstanceCommandCommand.name);
constructor(
private readonly instanceMigrationGenerationService: InstanceCommandGenerationService,
) {
super();
}
@Option({
flags: '-n, --name <name>',
description: 'Migration name (kebab-case)',
defaultValue: 'auto-generated',
})
parseName(value: string): string {
return value;
}
@Option({
flags: '-t, --type <type>',
description:
'Command type: fast (schema diff) or slow (data migration + DDL)',
defaultValue: 'fast',
})
parseType(value: string): InstanceCommandType {
if (value !== 'fast' && value !== 'slow') {
throw new Error(`Invalid type "${value}". Must be "fast" or "slow".`);
}
return value;
}
@Option({
flags: '--version <version>',
description: 'Target version (e.g. 1.23.0). Defaults to CURRENT_VERSION.',
})
parseVersion(value: string): TwentyAllVersion {
if (
!TWENTY_ALL_VERSIONS.includes(
value as (typeof TWENTY_ALL_VERSIONS)[number],
)
) {
throw new Error(
`Invalid version "${value}". Must be one of: ${TWENTY_ALL_VERSIONS.join(', ')}`,
);
}
return value as TwentyAllVersion;
}
async run(
_passedParams: string[],
options: GenerateInstanceCommandOptions,
): Promise<void> {
const migrationName = options.name;
const version = options.version ?? TWENTY_CURRENT_VERSION;
const commandType = options.type;
this.logger.log(
`Generating ${commandType} instance command for version ${version}...`,
);
const versionDir = this.getVersionDir(version);
const timestamp = Date.now();
const result =
await this.instanceMigrationGenerationService.generateInstanceCommand({
migrationName,
version,
timestamp,
type: commandType,
});
if (!result) {
this.logger.warn(
'No changes in database schema were found - cannot generate a migration.',
);
return;
}
const filePath = path.join(versionDir, result.fileName);
fs.writeFileSync(filePath, result.fileTemplate);
this.logger.log(
`${commandType} instance command generated successfully: ${filePath}`,
);
this.logger.log(` Class: ${result.className}`);
this.logger.log(` Version: ${version}`);
const versionSlug = version.split('.').slice(0, 2).join('-');
const newImportPath = `src/database/commands/upgrade-version-command/${versionSlug}/${result.fileName.replace('.ts', '')}`;
this.appendToInstanceCommandsConstant(result.className, newImportPath);
}
private getVersionDir(version: string): string {
const versionSlug = version.split('.').slice(0, 2).join('-');
return path.join(UPGRADE_VERSION_COMMAND_DIR, versionSlug);
}
private appendToInstanceCommandsConstant(
className: string,
importPath: string,
): void {
const filePath = path.join(
UPGRADE_VERSION_COMMAND_DIR,
'instance-commands.constant.ts',
);
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(className)) {
throw new Error(
`${className} is already registered in instance-commands.constant.ts`,
);
}
const newImportLine = `import { ${className} } from '${importPath}';\n`;
const updatedContent = content
.replace(/\nexport const/, `${newImportLine}\nexport const`)
.replace(/\];/, ` ${className},\n];`);
fs.writeFileSync(filePath, updatedContent);
this.logger.log(`Added ${className} to instance-commands.constant.ts`);
}
}