Files
twenty/packages/twenty-server/src/database/commands/instance-command-generation.service.ts
T
Paul RastoinandGitHub 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

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')}
}
}
`;
}
}