# 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
169 lines
4.7 KiB
TypeScript
169 lines
4.7 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
|
|
import { pascalCase } from 'twenty-shared/utils';
|
|
import { DataSource } from 'typeorm';
|
|
|
|
import { type TwentyAllVersion } from 'src/engine/core-modules/upgrade/constants/twenty-all-versions.constant';
|
|
import { type InstanceCommandType } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
|
|
|
type GenerateInstanceCommandArgs = {
|
|
migrationName: string;
|
|
version: TwentyAllVersion;
|
|
timestamp: number;
|
|
type?: InstanceCommandType;
|
|
};
|
|
|
|
export type GeneratedMigrationResult = {
|
|
fileName: string;
|
|
fileTemplate: string;
|
|
className: string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class InstanceCommandGenerationService {
|
|
constructor(
|
|
@InjectDataSource()
|
|
private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
async generateInstanceCommand({
|
|
migrationName,
|
|
version,
|
|
timestamp,
|
|
type = 'fast',
|
|
}: GenerateInstanceCommandArgs): Promise<GeneratedMigrationResult | null> {
|
|
const sqlInMemory = await this.dataSource.driver
|
|
.createSchemaBuilder()
|
|
.log();
|
|
|
|
if (sqlInMemory.upQueries.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const className = this.buildClassName({ name: migrationName, type });
|
|
|
|
const upStatements = sqlInMemory.upQueries.map(
|
|
({ query, parameters }) =>
|
|
` await queryRunner.query('${this.escapeForSingleQuotedString(query)}'${this.formatQueryParams(parameters)});`,
|
|
);
|
|
|
|
const downStatements = sqlInMemory.downQueries
|
|
.reverse()
|
|
.map(
|
|
({ query, parameters }) =>
|
|
` await queryRunner.query('${this.escapeForSingleQuotedString(query)}'${this.formatQueryParams(parameters)});`,
|
|
);
|
|
|
|
const fileTemplate =
|
|
type === 'slow'
|
|
? this.buildSlowMigrationFileContent({
|
|
className,
|
|
version,
|
|
timestamp,
|
|
upStatements,
|
|
downStatements,
|
|
})
|
|
: this.buildFastMigrationFileContent({
|
|
className,
|
|
version,
|
|
timestamp,
|
|
upStatements,
|
|
downStatements,
|
|
});
|
|
|
|
const versionSlug = version.split('.').slice(0, 2).join('-');
|
|
const fileName = `${versionSlug}-instance-command-${type}-${timestamp}-${migrationName}.ts`;
|
|
|
|
return { fileName, fileTemplate, className };
|
|
}
|
|
|
|
private buildClassName({
|
|
name,
|
|
type,
|
|
}: {
|
|
name: string;
|
|
type: InstanceCommandType;
|
|
}): string {
|
|
return `${pascalCase(name)}${pascalCase(type)}InstanceCommand`;
|
|
}
|
|
|
|
private formatQueryParams(parameters: unknown[] | undefined): string {
|
|
if (!parameters || !parameters.length) {
|
|
return '';
|
|
}
|
|
|
|
return `, ${JSON.stringify(parameters)}`;
|
|
}
|
|
|
|
private escapeForSingleQuotedString(query: string): string {
|
|
return query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
}
|
|
|
|
private buildFastMigrationFileContent({
|
|
className,
|
|
version,
|
|
timestamp,
|
|
upStatements,
|
|
downStatements,
|
|
}: {
|
|
className: string;
|
|
version: string;
|
|
timestamp: number;
|
|
upStatements: string[];
|
|
downStatements: string[];
|
|
}): string {
|
|
return `import { QueryRunner } from 'typeorm';
|
|
|
|
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
|
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
|
|
|
@RegisteredInstanceCommand('${version}', ${timestamp})
|
|
export class ${className} implements FastInstanceCommand {
|
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
${upStatements.join('\n')}
|
|
}
|
|
|
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
${downStatements.join('\n')}
|
|
}
|
|
}
|
|
`;
|
|
}
|
|
|
|
private buildSlowMigrationFileContent({
|
|
className,
|
|
version,
|
|
timestamp,
|
|
upStatements,
|
|
downStatements,
|
|
}: {
|
|
className: string;
|
|
version: string;
|
|
timestamp: number;
|
|
upStatements: string[];
|
|
downStatements: string[];
|
|
}): string {
|
|
return `import { DataSource, QueryRunner } from 'typeorm';
|
|
|
|
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
|
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
|
|
|
|
@RegisteredInstanceCommand('${version}', ${timestamp}, { type: 'slow' })
|
|
export class ${className} implements SlowInstanceCommand {
|
|
async runDataMigration(dataSource: DataSource): Promise<void> {
|
|
// TODO: implement data backfill before the DDL migration
|
|
}
|
|
|
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
${upStatements.join('\n')}
|
|
}
|
|
|
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
${downStatements.join('\n')}
|
|
}
|
|
}
|
|
`;
|
|
}
|
|
}
|