21142d98fe
# 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
164 lines
4.6 KiB
TypeScript
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`);
|
|
}
|
|
}
|