From 85be4634877515bb6e659e8ea931d5e96e8ab738 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:09:29 +0200 Subject: [PATCH] Slow instance commands (#19431) # Introduction This PR introduces the slow instance commands pattern, that allow migrating data in prior of the schema migration, that would fail if not. Slow instance commands runs after the fast instance commands and before the workspace commands. On twenty instance that do not has any active or suspended workspace the data migration part is skipped but the migration still runs, especially for fresh installs We were previously hacking through typeorm transaction system to gain such granularity using save points: ```ts export class AddPayloadToCommandMenuItem1775129635528 implements MigrationInterface { name = 'AddPayloadToCommandMenuItem1775129635528'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "core"."commandMenuItem" ADD "payload" jsonb`, ); const savepointName = 'sp_add_payload_check_constraint_to_command_menu_item'; try { await queryRunner.query(`SAVEPOINT ${savepointName}`); await addPayloadCheckConstraintToCommandMenuItem(queryRunner); await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`); } catch (e) { try { await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`); await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`); } catch (rollbackError) { // oxlint-disable-next-line no-console console.error( 'Failed to rollback to savepoint in AddPayloadToCommandMenuItem1775129635528', rollbackError, ); throw rollbackError; } // oxlint-disable-next-line no-console console.error( 'Swallowing AddPayloadToCommandMenuItem1775129635528 error', e, ); } } ``` It was afterwards re-applied within an workspace commands, it was hacky and missleading for the self host having false positive in logs ## New pattern Generate the slow instance command ``` npx nx database:migrate:generate twenty-server -- --name add-foo-bar-columns --type slow ``` ```ts 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('1.21.0', 1775640902366, { type: 'slow' }) export class AddPrastoinColToWorkspaceSlowInstanceCommand implements SlowInstanceCommand { async runDataMigration(dataSource: DataSource): Promise { // TODO: implement data backfill before the DDL migration } public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('ALTER TABLE "core"."workspace" ADD "prastoin" character varying'); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query('ALTER TABLE "core"."workspace" DROP COLUMN "prastoin"'); } } ``` ### Why `run-instance-commands` and `upgrade` remain separate commands These two commands serve fundamentally different purposes with incompatible scoping semantics: The run-instance-commands iterates over all the legacy typeorm and the instance commands of all versions, used for database init and so on. we could be centralizing both but the readability tradeoff isn't worth it In the future thanks to the cross-version pattern we will be able to centralize them but not right now Please note that by default the `run-instance-commands` only run the fast instance commands which is expected for our cloud prod CD --- ...ce-command-generation.service.spec.ts.snap | 80 +++-- ...nstance-command-generation.service.spec.ts | 72 ++++- .../generate-instance-command.command.ts | 42 ++- .../instance-command-generation.service.ts | 88 ++++-- .../commands/run-instance-commands.command.ts | 104 +++---- .../__tests__/upgrade.command.spec.ts | 278 ++++++++++++------ .../upgrade.command.ts | 102 +++---- .../registered-instance-command.decorator.ts | 11 +- .../fast-instance-command.interface.ts | 6 + .../slow-instance-command.interface.ts | 7 + .../upgrade-command-registry.service.spec.ts | 254 ++++++++++++---- .../services/instance-upgrade.service.ts | 84 +++++- .../upgrade-command-registry.service.ts | 151 +++++----- 13 files changed, 873 insertions(+), 406 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface.ts create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface.ts diff --git a/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap b/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap index 3203ff7d6c..b24073cffc 100644 --- a/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap +++ b/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap @@ -2,14 +2,15 @@ exports[`InstanceCommandGenerationService should encode version correctly in file and class names 1`] = ` { - "className": "TestCommand", + "className": "TestFastInstanceCommand", "fileName": "1-20-instance-command-fast-1775000000000-test.ts", - "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm'; + "fileTemplate": "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('1.20.0', 1775000000000) -export class TestCommand implements MigrationInterface { +export class TestFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('SELECT 1'); } @@ -24,14 +25,15 @@ export class TestCommand implements MigrationInterface { exports[`InstanceCommandGenerationService should escape backslashes in SQL queries 1`] = ` { - "className": "UpdatePathCommand", + "className": "UpdatePathFastInstanceCommand", "fileName": "1-21-instance-command-fast-1775000000000-update-path.ts", - "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm'; + "fileTemplate": "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('1.21.0', 1775000000000) -export class UpdatePathCommand implements MigrationInterface { +export class UpdatePathFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('UPDATE "core"."config" SET "value" = E\\'path\\\\\\\\to\\\\\\\\file\\''); } @@ -46,14 +48,15 @@ export class UpdatePathCommand implements MigrationInterface { exports[`InstanceCommandGenerationService should escape single quotes in SQL queries 1`] = ` { - "className": "UpdateConfigCommand", + "className": "UpdateConfigFastInstanceCommand", "fileName": "1-21-instance-command-fast-1775000000000-update-config.ts", - "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm'; + "fileTemplate": "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('1.21.0', 1775000000000) -export class UpdateConfigCommand implements MigrationInterface { +export class UpdateConfigFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('UPDATE "core"."config" SET "value" = \\'it\\'\\'s done\\''); } @@ -68,14 +71,15 @@ export class UpdateConfigCommand implements MigrationInterface { exports[`InstanceCommandGenerationService should generate a migration with a single up/down query 1`] = ` { - "className": "AddFooColumnCommand", + "className": "AddFooColumnFastInstanceCommand", "fileName": "1-21-instance-command-fast-1775000000000-add-foo-column.ts", - "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm'; + "fileTemplate": "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('1.21.0', 1775000000000) -export class AddFooColumnCommand implements MigrationInterface { +export class AddFooColumnFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('ALTER TABLE "core"."user" ADD "foo" varchar'); } @@ -90,14 +94,15 @@ export class AddFooColumnCommand implements MigrationInterface { exports[`InstanceCommandGenerationService should generate a migration with multiple queries 1`] = ` { - "className": "CreateTaskTableCommand", + "className": "CreateTaskTableFastInstanceCommand", "fileName": "1-21-instance-command-fast-1775000000000-create-task-table.ts", - "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm'; + "fileTemplate": "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('1.21.0', 1775000000000) -export class CreateTaskTableCommand implements MigrationInterface { +export class CreateTaskTableFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('CREATE TABLE "core"."task" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" varchar NOT NULL)'); await queryRunner.query('ALTER TABLE "core"."task" ADD CONSTRAINT "PK_task" PRIMARY KEY ("id")'); @@ -114,14 +119,15 @@ export class CreateTaskTableCommand implements MigrationInterface { exports[`InstanceCommandGenerationService should generate a migration with query parameters 1`] = ` { - "className": "SeedSettingCommand", + "className": "SeedSettingFastInstanceCommand", "fileName": "1-21-instance-command-fast-1775000000000-seed-setting.ts", - "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm'; + "fileTemplate": "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('1.21.0', 1775000000000) -export class SeedSettingCommand implements MigrationInterface { +export class SeedSettingFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('INSERT INTO "core"."setting" ("key", "value") VALUES ($1, $2)', ["theme","dark"]); } @@ -134,16 +140,44 @@ export class SeedSettingCommand implements MigrationInterface { } `; -exports[`InstanceCommandGenerationService should use default migration name in class and file names 1`] = ` +exports[`InstanceCommandGenerationService should generate a slow instance command with populated up/down 1`] = ` { - "className": "AutoGeneratedCommand", - "fileName": "1-21-instance-command-fast-1775000000000-auto-generated.ts", - "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm'; + "className": "MakeColumnNotNullableSlowInstanceCommand", + "fileName": "1-21-instance-command-slow-1775000000000-make-column-not-nullable.ts", + "fileTemplate": "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('1.21.0', 1775000000000, { type: 'slow' }) +export class MakeColumnNotNullableSlowInstanceCommand implements SlowInstanceCommand { + async runDataMigration(dataSource: DataSource): Promise { + // TODO: implement data backfill before the DDL migration + } + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "core"."user" ALTER COLUMN "email" SET NOT NULL'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "core"."user" ALTER COLUMN "email" DROP NOT NULL'); + } +} +", +} +`; + +exports[`InstanceCommandGenerationService should use default migration name in class and file names 1`] = ` +{ + "className": "AutoGeneratedFastInstanceCommand", + "fileName": "1-21-instance-command-fast-1775000000000-auto-generated.ts", + "fileTemplate": "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('1.21.0', 1775000000000) -export class AutoGeneratedCommand implements MigrationInterface { +export class AutoGeneratedFastInstanceCommand implements FastInstanceCommand { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('ALTER TABLE "core"."user" ADD "bar" integer'); } diff --git a/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts b/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts index 1f09ce246f..c9e0fe54f0 100644 --- a/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts +++ b/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts @@ -37,7 +37,7 @@ describe('InstanceCommandGenerationService', () => { it('should return null when no schema changes are detected', async () => { const service = await buildService(); - const result = await service.generate({ + const result = await service.generateInstanceCommand({ migrationName: 'no-changes', version: '1.21.0', timestamp: FIXED_TIMESTAMP, @@ -52,7 +52,7 @@ describe('InstanceCommandGenerationService', () => { [{ query: 'ALTER TABLE "core"."user" DROP COLUMN "foo"' }], ); - const result = await service.generate({ + const result = await service.generateInstanceCommand({ migrationName: 'add-foo-column', version: '1.21.0', timestamp: FIXED_TIMESTAMP, @@ -79,7 +79,7 @@ describe('InstanceCommandGenerationService', () => { ], ); - const result = await service.generate({ + const result = await service.generateInstanceCommand({ migrationName: 'create-task-table', version: '1.21.0', timestamp: FIXED_TIMESTAMP, @@ -105,7 +105,7 @@ describe('InstanceCommandGenerationService', () => { ], ); - const result = await service.generate({ + const result = await service.generateInstanceCommand({ migrationName: 'seed-setting', version: '1.21.0', timestamp: FIXED_TIMESTAMP, @@ -120,7 +120,7 @@ describe('InstanceCommandGenerationService', () => { [{ query: 'UPDATE "core"."config" SET "value" = \'original\'' }], ); - const result = await service.generate({ + const result = await service.generateInstanceCommand({ migrationName: 'update-config', version: '1.21.0', timestamp: FIXED_TIMESTAMP, @@ -139,7 +139,7 @@ describe('InstanceCommandGenerationService', () => { [{ query: 'UPDATE "core"."config" SET "value" = NULL' }], ); - const result = await service.generate({ + const result = await service.generateInstanceCommand({ migrationName: 'update-path', version: '1.21.0', timestamp: FIXED_TIMESTAMP, @@ -154,7 +154,7 @@ describe('InstanceCommandGenerationService', () => { [{ query: 'ALTER TABLE "core"."user" DROP COLUMN "bar"' }], ); - const result = await service.generate({ + const result = await service.generateInstanceCommand({ migrationName: 'auto-generated', version: '1.21.0', timestamp: FIXED_TIMESTAMP, @@ -169,7 +169,7 @@ describe('InstanceCommandGenerationService', () => { [{ query: 'SELECT 1' }], ); - const result = await service.generate({ + const result = await service.generateInstanceCommand({ migrationName: 'test', version: '1.20.0', timestamp: FIXED_TIMESTAMP, @@ -177,4 +177,60 @@ describe('InstanceCommandGenerationService', () => { expect(result).toMatchSnapshot(); }); + + it('should return null for slow type when no schema changes are detected', async () => { + const service = await buildService(); + + const result = await service.generateInstanceCommand({ + migrationName: 'no-changes', + version: '1.21.0', + timestamp: FIXED_TIMESTAMP, + type: 'slow', + }); + + expect(result).toBeNull(); + }); + + it('should generate a slow instance command with populated up/down', async () => { + const service = await buildService( + [ + { + query: 'ALTER TABLE "core"."user" ALTER COLUMN "email" SET NOT NULL', + }, + ], + [ + { + query: 'ALTER TABLE "core"."user" ALTER COLUMN "email" DROP NOT NULL', + }, + ], + ); + + const result = await service.generateInstanceCommand({ + migrationName: 'make-column-not-nullable', + version: '1.21.0', + timestamp: FIXED_TIMESTAMP, + type: 'slow', + }); + + expect(result).toMatchSnapshot(); + }); + + it('should use correct file naming for slow instance commands', async () => { + const service = await buildService( + [{ query: 'SELECT 1' }], + [{ query: 'SELECT 1' }], + ); + + const result = await service.generateInstanceCommand({ + migrationName: 'backfill-data', + version: '1.20.0', + timestamp: FIXED_TIMESTAMP, + type: 'slow', + }); + + expect(result?.fileName).toBe( + '1-20-instance-command-slow-1775000000000-backfill-data.ts', + ); + expect(result?.className).toBe('BackfillDataSlowInstanceCommand'); + }); }); diff --git a/packages/twenty-server/src/database/commands/generate-instance-command.command.ts b/packages/twenty-server/src/database/commands/generate-instance-command.command.ts index 9a254ff88b..adac4c1e22 100644 --- a/packages/twenty-server/src/database/commands/generate-instance-command.command.ts +++ b/packages/twenty-server/src/database/commands/generate-instance-command.command.ts @@ -7,6 +7,7 @@ import { Command, CommandRunner, Option } from 'nest-commander'; import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service'; import { UPGRADE_COMMAND_SUPPORTED_VERSIONS } from 'src/engine/constants/upgrade-command-supported-versions.constant'; +import { type InstanceCommandType } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; const UPGRADE_VERSION_COMMAND_DIR = path.resolve( process.cwd(), @@ -15,6 +16,7 @@ const UPGRADE_VERSION_COMMAND_DIR = path.resolve( type GenerateInstanceCommandOptions = { name: string; + type: InstanceCommandType; }; @Command({ @@ -40,6 +42,20 @@ export class GenerateInstanceCommandCommand extends CommandRunner { return value; } + @Option({ + flags: '-t, --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; + } + async run( _passedParams: string[], options: GenerateInstanceCommandOptions, @@ -52,16 +68,22 @@ export class GenerateInstanceCommandCommand extends CommandRunner { throw new Error('No supported versions found'); } - this.logger.log(`Generating versioned migration for version ${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.generate({ - migrationName, - version, - timestamp, - }); + const result = + await this.instanceMigrationGenerationService.generateInstanceCommand({ + migrationName, + version, + timestamp, + type: commandType, + }); if (!result) { this.logger.warn( @@ -71,11 +93,13 @@ export class GenerateInstanceCommandCommand extends CommandRunner { return; } - const migrationFilePath = path.join(versionDir, result.fileName); + const filePath = path.join(versionDir, result.fileName); - fs.writeFileSync(migrationFilePath, result.fileTemplate); + fs.writeFileSync(filePath, result.fileTemplate); - this.logger.log(`Migration generated successfully: ${migrationFilePath}`); + this.logger.log( + `${commandType} instance command generated successfully: ${filePath}`, + ); this.logger.log(` Class: ${result.className}`); this.logger.log(` Version: ${version}`); diff --git a/packages/twenty-server/src/database/commands/instance-command-generation.service.ts b/packages/twenty-server/src/database/commands/instance-command-generation.service.ts index c4b3d32d26..a7cc58f21e 100644 --- a/packages/twenty-server/src/database/commands/instance-command-generation.service.ts +++ b/packages/twenty-server/src/database/commands/instance-command-generation.service.ts @@ -5,11 +5,13 @@ import { pascalCase } from 'twenty-shared/utils'; import { DataSource } from 'typeorm'; import { type UpgradeCommandVersion } from 'src/engine/constants/upgrade-command-supported-versions.constant'; +import { type InstanceCommandType } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; -type GenerateMigrationArgs = { +type GenerateInstanceCommandArgs = { migrationName: string; version: UpgradeCommandVersion; timestamp: number; + type?: InstanceCommandType; }; export type GeneratedMigrationResult = { @@ -25,11 +27,12 @@ export class InstanceCommandGenerationService { private readonly dataSource: DataSource, ) {} - async generate({ + async generateInstanceCommand({ migrationName, version, timestamp, - }: GenerateMigrationArgs): Promise { + type = 'fast', + }: GenerateInstanceCommandArgs): Promise { const sqlInMemory = await this.dataSource.driver .createSchemaBuilder() .log(); @@ -38,7 +41,7 @@ export class InstanceCommandGenerationService { return null; } - const className = this.buildClassName(migrationName); + const className = this.buildClassName({ name: migrationName, type }); const upStatements = sqlInMemory.upQueries.map( ({ query, parameters }) => @@ -52,22 +55,37 @@ export class InstanceCommandGenerationService { ` await queryRunner.query('${this.escapeForSingleQuotedString(query)}'${this.formatQueryParams(parameters)});`, ); - const fileTemplate = this.buildMigrationFileContent({ - className, - version, - timestamp, - upStatements, - downStatements, - }); + 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-fast-${timestamp}-${migrationName}.ts`; + const fileName = `${versionSlug}-instance-command-${type}-${timestamp}-${migrationName}.ts`; return { fileName, fileTemplate, className }; } - private buildClassName(name: string): string { - return `${pascalCase(name)}Command`; + private buildClassName({ + name, + type, + }: { + name: string; + type: InstanceCommandType; + }): string { + return `${pascalCase(name)}${pascalCase(type)}InstanceCommand`; } private formatQueryParams(parameters: unknown[] | undefined): string { @@ -82,7 +100,7 @@ export class InstanceCommandGenerationService { return query.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); } - private buildMigrationFileContent({ + private buildFastMigrationFileContent({ className, version, timestamp, @@ -95,12 +113,48 @@ export class InstanceCommandGenerationService { upStatements: string[]; downStatements: string[]; }): string { - return `import { MigrationInterface, QueryRunner } from 'typeorm'; + 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 MigrationInterface { +export class ${className} implements FastInstanceCommand { + public async up(queryRunner: QueryRunner): Promise { +${upStatements.join('\n')} + } + + public async down(queryRunner: QueryRunner): Promise { +${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 { + // TODO: implement data backfill before the DDL migration + } + public async up(queryRunner: QueryRunner): Promise { ${upStatements.join('\n')} } diff --git a/packages/twenty-server/src/database/commands/run-instance-commands.command.ts b/packages/twenty-server/src/database/commands/run-instance-commands.command.ts index 3334cefc00..a6ff960656 100644 --- a/packages/twenty-server/src/database/commands/run-instance-commands.command.ts +++ b/packages/twenty-server/src/database/commands/run-instance-commands.command.ts @@ -3,7 +3,6 @@ import { InjectDataSource } from '@nestjs/typeorm'; import chalk from 'chalk'; import { Command, CommandRunner, Option } from 'nest-commander'; -import { isDefined } from 'twenty-shared/utils'; import { DataSource } from 'typeorm'; import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service'; @@ -13,6 +12,7 @@ import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace- type RunInstanceCommandsOptions = { force?: boolean; + includeSlow?: boolean; }; @Command({ @@ -43,6 +43,15 @@ export class RunInstanceCommandsCommand extends CommandRunner { 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, @@ -50,7 +59,43 @@ export class RunInstanceCommandsCommand extends CommandRunner { try { await this.checkWorkspaceVersionSafety(options); await this.runLegacyPendingTypeOrmMigrations(); - await this.runAllInstanceCommands(); + + for (const { + command, + name, + } of this.upgradeCommandRegistryService.getAllFastInstanceCommands()) { + const result = await this.instanceUpgradeService.runFastInstanceCommand( + { + command, + name, + }, + ); + + if (result.status === 'failed') { + throw result.error; + } + } + + if (options.includeSlow) { + const hasWorkspaces = + await this.workspaceVersionService.hasActiveOrSuspendedWorkspaces(); + + for (const { + command, + name, + } of this.upgradeCommandRegistryService.getAllSlowInstanceCommands()) { + const result = + await this.instanceUpgradeService.runSlowInstanceCommand({ + command, + name, + skipDataMigration: !hasWorkspaces, + }); + + if (result.status === 'failed') { + throw result.error; + } + } + } this.logger.log(chalk.green('Instance commands completed')); } catch (error) { @@ -77,61 +122,6 @@ export class RunInstanceCommandsCommand extends CommandRunner { } } - private async runAllInstanceCommands(): Promise { - const allInstanceCommands = - this.upgradeCommandRegistryService.getAllInstanceCommands(); - - if (allInstanceCommands.length === 0) { - this.logger.log('No registered instance commands'); - - return; - } - - this.logger.log( - `Running ${allInstanceCommands.length} instance command(s) across all versions...`, - ); - - for (const { version, migration } of allInstanceCommands) { - const migrationName = migration.constructor.name; - const result = - await this.instanceUpgradeService.runSingleMigration(migration); - - switch (result.status) { - case 'already-executed': { - this.logger.log( - `Instance command ${migrationName} (${version}) already executed, skipping`, - ); - - break; - } - case 'failed': { - this.logger.error( - `Instance command ${migrationName} (${version}) failed`, - ); - - if (isDefined(result.error)) { - this.logger.error( - result.error instanceof Error - ? (result.error.stack ?? result.error.message) - : String(result.error), - ); - } - - throw new Error( - `Instance command ${migrationName} (${version}) failed`, - ); - } - case 'success': { - this.logger.log( - `Instance command ${migrationName} (${version}) executed successfully`, - ); - - break; - } - } - } - } - private async checkWorkspaceVersionSafety( options: RunInstanceCommandsOptions, ): Promise { diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/__tests__/upgrade.command.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/__tests__/upgrade.command.spec.ts index 600a7eabf9..e1d1714db6 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/__tests__/upgrade.command.spec.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/__tests__/upgrade.command.spec.ts @@ -3,11 +3,10 @@ import { eachTestingContextFilter, type EachTestingContext, } from 'twenty-shared/testing'; -import { - type DataSource, - type MigrationInterface, - type QueryRunner, -} from 'typeorm'; +import { type DataSource, type QueryRunner } from 'typeorm'; + +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; +import { type SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; import { getDataSourceToken } from '@nestjs/typeorm'; @@ -61,7 +60,7 @@ type BuildUpgradeCommandModuleArgs = { workspaces: WorkspaceEntity[]; appVersion: string | null; commandRunner: CommandRunnerValues; - migrations?: MigrationInterface[]; + migrations?: FastInstanceCommand[]; }; const buildUpgradeCommandModule = async ({ workspaces, @@ -92,8 +91,11 @@ const buildUpgradeCommandModule = async ({ : { provide: UpgradeCommandRegistryService, useValue: { - getInstanceCommandsForVersion: jest.fn().mockReturnValue([]), - getWorkspaceCommandsForVersion: jest.fn().mockReturnValue([]), + getBundleForVersion: jest.fn().mockReturnValue({ + fastInstanceCommands: [], + slowInstanceCommands: [], + workspaceCommands: [], + }), }, }; @@ -184,7 +186,10 @@ const buildUpgradeCommandModule = async ({ { provide: InstanceUpgradeService, useValue: { - runSingleMigration: jest + runFastInstanceCommand: jest + .fn() + .mockResolvedValue({ status: 'success' }), + runSlowInstanceCommand: jest .fn() .mockResolvedValue({ status: 'success' }), }, @@ -242,7 +247,7 @@ describe('UpgradeCommandRunner', () => { workspaces?: WorkspaceEntity[]; appVersion?: string | null; commandRunner?: CommandRunnerValues; - migrations?: MigrationInterface[]; + migrations?: FastInstanceCommand[]; }; const buildModuleAndSetupSpies = async ({ numberOfWorkspace = 1, @@ -365,26 +370,26 @@ describe('UpgradeCommandRunner', () => { ); }); - it('should call runSingleMigration for each current-version instance command', async () => { + it('should call runFastInstanceCommand for each current-version instance command', async () => { @RegisteredInstanceCommand(CURRENT_VERSION, 1770000000000) - class AddIndexToUsers1770000000000 implements MigrationInterface { + class AddIndexToUsers1770000000000 implements FastInstanceCommand { async up(_queryRunner: QueryRunner) {} async down(_queryRunner: QueryRunner) {} } @RegisteredInstanceCommand(CURRENT_VERSION, 1771000000000) - class AddColumnToAccounts1771000000000 implements MigrationInterface { + class AddColumnToAccounts1771000000000 implements FastInstanceCommand { async up(_queryRunner: QueryRunner) {} async down(_queryRunner: QueryRunner) {} } @RegisteredInstanceCommand(PREVIOUS_VERSION, 1769000000000) - class DropLegacyTable1769000000000 implements MigrationInterface { + class DropLegacyTable1769000000000 implements FastInstanceCommand { async up(_queryRunner: QueryRunner) {} async down(_queryRunner: QueryRunner) {} } - class UndecoratedMigration1768000000000 implements MigrationInterface { + class UndecoratedMigration1768000000000 implements FastInstanceCommand { async up(_queryRunner: QueryRunner) {} async down(_queryRunner: QueryRunner) {} } @@ -400,105 +405,46 @@ describe('UpgradeCommandRunner', () => { const instanceUpgradeService = module.get(InstanceUpgradeService); - const passedParams: string[] = []; - const options: UpgradeCommandOptions = {}; + await upgradeCommandRunner.run([], {}); - await upgradeCommandRunner.run(passedParams, options); - - expect(instanceUpgradeService.runSingleMigration).toHaveBeenCalledTimes(2); - expect(instanceUpgradeService.runSingleMigration).toHaveBeenNthCalledWith( - 1, - addIndex, - ); - expect(instanceUpgradeService.runSingleMigration).toHaveBeenNthCalledWith( + expect(instanceUpgradeService.runFastInstanceCommand).toHaveBeenCalledTimes( 2, - addColumn, ); + expect( + instanceUpgradeService.runFastInstanceCommand, + ).toHaveBeenNthCalledWith(1, { + command: addIndex, + name: `${CURRENT_VERSION}_AddIndexToUsers1770000000000_1770000000000`, + }); + expect( + instanceUpgradeService.runFastInstanceCommand, + ).toHaveBeenNthCalledWith(2, { + command: addColumn, + name: `${CURRENT_VERSION}_AddColumnToAccounts1771000000000_1771000000000`, + }); }); - it('should skip already-executed instance commands', async () => { + it('should propagate errors from runFastInstanceCommand', async () => { @RegisteredInstanceCommand(CURRENT_VERSION, 1770000000000) - class AlreadyRunMigration1770000000000 implements MigrationInterface { + class FailingMigration1770000000000 implements FastInstanceCommand { async up(_queryRunner: QueryRunner) {} async down(_queryRunner: QueryRunner) {} } - const alreadyRun = new AlreadyRunMigration1770000000000(); - const module = await buildModuleAndSetupSpies({ - migrations: [alreadyRun], + migrations: [new FailingMigration1770000000000()], }); const instanceUpgradeService = module.get(InstanceUpgradeService); - (instanceUpgradeService.runSingleMigration as jest.Mock).mockResolvedValue({ - status: 'already-executed', - }); - - const passedParams: string[] = []; - const options: UpgradeCommandOptions = {}; - - await upgradeCommandRunner.run(passedParams, options); - - expect(upgradeCommandRunner['logger'].warn).toHaveBeenCalledWith( - expect.stringContaining('already executed'), - ); - }); - - it('should throw when a migration fails', async () => { - @RegisteredInstanceCommand(CURRENT_VERSION, 1770000000000) - class FailingMigration1770000000000 implements MigrationInterface { - async up(_queryRunner: QueryRunner) {} - async down(_queryRunner: QueryRunner) {} - } - - const failing = new FailingMigration1770000000000(); - - const module = await buildModuleAndSetupSpies({ - migrations: [failing], - }); - - const instanceUpgradeService = module.get(InstanceUpgradeService); - - (instanceUpgradeService.runSingleMigration as jest.Mock).mockResolvedValue({ + ( + instanceUpgradeService.runFastInstanceCommand as jest.Mock + ).mockResolvedValue({ status: 'failed', error: new Error('SQL error'), }); - const passedParams: string[] = []; - const options: UpgradeCommandOptions = {}; - - await expect( - upgradeCommandRunner.run(passedParams, options), - ).rejects.toThrow('Core migration FailingMigration1770000000000 failed'); - }); - - it('should log success when a migration succeeds', async () => { - @RegisteredInstanceCommand(CURRENT_VERSION, 1770000000000) - class SuccessMigration1770000000000 implements MigrationInterface { - async up(_queryRunner: QueryRunner) {} - async down(_queryRunner: QueryRunner) {} - } - - const success = new SuccessMigration1770000000000(); - - const module = await buildModuleAndSetupSpies({ - migrations: [success], - }); - - const instanceUpgradeService = module.get(InstanceUpgradeService); - - const passedParams: string[] = []; - const options: UpgradeCommandOptions = {}; - - await upgradeCommandRunner.run(passedParams, options); - - expect(instanceUpgradeService.runSingleMigration).toHaveBeenCalledWith( - success, - ); - expect(upgradeCommandRunner['logger'].log).toHaveBeenCalledWith( - expect.stringContaining('executed successfully'), - ); + await expect(upgradeCommandRunner.run([], {})).rejects.toThrow('SQL error'); }); describe('Workspace upgrade should fail', () => { @@ -565,4 +511,146 @@ describe('UpgradeCommandRunner', () => { }, ); }); + + it('should call runSlowInstanceCommand for each current-version slow command', async () => { + @RegisteredInstanceCommand(CURRENT_VERSION, 1780000000000, { + type: 'slow', + }) + class SlowMigration1780000000000 implements SlowInstanceCommand { + async runDataMigration(_dataSource: DataSource): Promise {} + async up(_queryRunner: QueryRunner) {} + async down(_queryRunner: QueryRunner) {} + } + + const slowMigration = new SlowMigration1780000000000(); + + const module = await buildModuleAndSetupSpies({ + migrations: [slowMigration], + }); + + const instanceUpgradeService = module.get(InstanceUpgradeService); + + await upgradeCommandRunner.run([], {}); + + expect(instanceUpgradeService.runSlowInstanceCommand).toHaveBeenCalledTimes( + 1, + ); + expect(instanceUpgradeService.runSlowInstanceCommand).toHaveBeenCalledWith({ + command: slowMigration, + name: `${CURRENT_VERSION}_SlowMigration1780000000000_1780000000000`, + skipDataMigration: false, + }); + }); + + it('should run slow commands after fast commands but before workspace commands', async () => { + const executionOrder: string[] = []; + + @RegisteredInstanceCommand(CURRENT_VERSION, 1770000000000) + class FastMigration1770000000000 implements FastInstanceCommand { + async up(_queryRunner: QueryRunner) {} + async down(_queryRunner: QueryRunner) {} + } + + @RegisteredInstanceCommand(CURRENT_VERSION, 1780000000000, { + type: 'slow', + }) + class SlowMigration1780000000000 implements SlowInstanceCommand { + async runDataMigration(_dataSource: DataSource): Promise {} + async up(_queryRunner: QueryRunner) {} + async down(_queryRunner: QueryRunner) {} + } + + const module = await buildModuleAndSetupSpies({ + migrations: [ + new FastMigration1770000000000(), + new SlowMigration1780000000000(), + ], + }); + + const instanceUpgradeService = module.get(InstanceUpgradeService); + + ( + instanceUpgradeService.runFastInstanceCommand as jest.Mock + ).mockImplementation(async () => { + executionOrder.push('fast'); + + return { status: 'success' }; + }); + + ( + instanceUpgradeService.runSlowInstanceCommand as jest.Mock + ).mockImplementation(async () => { + executionOrder.push('slow'); + + return { status: 'success' }; + }); + + const workspaceIteratorService = module.get(WorkspaceIteratorService); + + (workspaceIteratorService.iterate as jest.Mock).mockImplementation( + async () => { + executionOrder.push('workspace'); + + return { success: [], fail: [] }; + }, + ); + + await upgradeCommandRunner.run([], {}); + + expect(executionOrder).toStrictEqual(['fast', 'slow', 'workspace']); + }); + + it('should pass skipDataMigration: true on fresh install (no workspaces)', async () => { + @RegisteredInstanceCommand(CURRENT_VERSION, 1780000000000, { + type: 'slow', + }) + class SlowMigrationFreshInstall implements SlowInstanceCommand { + async runDataMigration(_dataSource: DataSource): Promise {} + async up(_queryRunner: QueryRunner) {} + async down(_queryRunner: QueryRunner) {} + } + + const module = await buildModuleAndSetupSpies({ + numberOfWorkspace: 0, + migrations: [new SlowMigrationFreshInstall()], + }); + + const instanceUpgradeService = module.get(InstanceUpgradeService); + + await upgradeCommandRunner.run([], {}); + + expect(instanceUpgradeService.runSlowInstanceCommand).toHaveBeenCalledWith({ + command: expect.any(SlowMigrationFreshInstall), + name: `${CURRENT_VERSION}_SlowMigrationFreshInstall_1780000000000`, + skipDataMigration: true, + }); + }); + + it('should propagate errors from runSlowInstanceCommand', async () => { + @RegisteredInstanceCommand(CURRENT_VERSION, 1780000000000, { + type: 'slow', + }) + class FailingSlowMigration implements SlowInstanceCommand { + async runDataMigration(_dataSource: DataSource): Promise {} + async up(_queryRunner: QueryRunner) {} + async down(_queryRunner: QueryRunner) {} + } + + const module = await buildModuleAndSetupSpies({ + migrations: [new FailingSlowMigration()], + }); + + const instanceUpgradeService = module.get(InstanceUpgradeService); + + ( + instanceUpgradeService.runSlowInstanceCommand as jest.Mock + ).mockResolvedValue({ + status: 'failed', + error: new Error('Data migration error'), + }); + + await expect(upgradeCommandRunner.run([], {})).rejects.toThrow( + 'Data migration error', + ); + }); }); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 0215e223c0..dc38627c90 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -3,8 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import chalk from 'chalk'; import { Command, CommandRunner, Option } from 'nest-commander'; import { SemVer } from 'semver'; -import { assertUnreachable, isDefined } from 'twenty-shared/utils'; -import { DataSource, MigrationInterface } from 'typeorm'; +import { DataSource } from 'typeorm'; import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; @@ -13,7 +12,10 @@ import { CommandLogger } from 'src/database/commands/logger'; import { type UpgradeCommandVersion } from 'src/engine/constants/upgrade-command-supported-versions.constant'; import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service'; import { InstanceUpgradeService } from 'src/engine/core-modules/upgrade/services/instance-upgrade.service'; -import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/services/upgrade-command-registry.service'; +import { + UpgradeCommandRegistryService, + type VersionBundle, +} from 'src/engine/core-modules/upgrade/services/upgrade-command-registry.service'; import { WorkspaceUpgradeService } from 'src/engine/core-modules/upgrade/services/workspace-upgrade.service'; import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service'; @@ -30,12 +32,10 @@ export type UpgradeCommandOptions = { verbose?: boolean; }; -type VersionContext = { +type VersionContext = VersionBundle & { fromWorkspaceVersion: SemVer; currentAppVersion: SemVer; currentVersionMajorMinor: UpgradeCommandVersion; - instanceCommands: MigrationInterface[]; - workspaceCommands: VersionCommands; }; @Command({ @@ -143,7 +143,8 @@ export class UpgradeCommand extends CommandRunner { 'Initialized upgrade context with:', `- currentVersion (migrating to): ${versionContext.currentAppVersion}`, `- fromWorkspaceVersion: ${versionContext.fromWorkspaceVersion}`, - `- ${versionContext.instanceCommands.length} instance commands (from registry)`, + `- ${versionContext.fastInstanceCommands.length} fast instance commands (from registry)`, + `- ${versionContext.slowInstanceCommands.length} slow instance commands (from registry)`, `- ${versionContext.workspaceCommands.length} workspace commands`, ].join('\n '), ), @@ -167,11 +168,37 @@ Please roll back to that version and run the upgrade command again.`, } await this.runLegacyPendingTypeOrmMigrations(); - await this.runInstanceCommandsOrThrow(versionContext); + + for (const { command, name } of versionContext.fastInstanceCommands) { + const result = await this.instanceUpgradeService.runFastInstanceCommand( + { + command, + name, + }, + ); + + if (result.status === 'failed') { + throw result.error; + } + } const hasWorkspaces = await this.workspaceVersionService.hasActiveOrSuspendedWorkspaces(); + for (const { command, name } of versionContext.slowInstanceCommands) { + const result = await this.instanceUpgradeService.runSlowInstanceCommand( + { + command, + name, + skipDataMigration: !hasWorkspaces, + }, + ); + + if (result.status === 'failed') { + throw result.error; + } + } + if (!hasWorkspaces) { this.logger.log( chalk.blue( @@ -220,64 +247,16 @@ Please roll back to that version and run the upgrade command again.`, } } - private async runInstanceCommandsOrThrow( - versionContext: VersionContext, - ): Promise { - for (const instanceCommand of versionContext.instanceCommands) { - const migrationName = instanceCommand.constructor.name; - const result = - await this.instanceUpgradeService.runSingleMigration(instanceCommand); - - switch (result.status) { - case 'already-executed': { - this.logger.warn( - `Core migration ${migrationName} already executed, skipping`, - ); - - break; - } - case 'failed': { - this.logger.error(`Core migration ${migrationName} failed`); - - if (isDefined(result.error)) { - this.logger.error( - result.error instanceof Error - ? (result.error.stack ?? result.error.message) - : String(result.error), - ); - } - - throw new Error(`Core migration ${migrationName} failed`); - } - case 'success': { - this.logger.log( - `Core migration ${migrationName} executed successfully`, - ); - - break; - } - default: { - assertUnreachable(result); - } - } - } - } - private resolveVersionContext(): VersionContext { const currentAppVersion = this.coreEngineVersionService.getCurrentVersion(); const currentVersionMajorMinor = `${currentAppVersion.major}.${currentAppVersion.minor}.0` as UpgradeCommandVersion; - const workspaceCommands = - this.upgradeCommandRegistryService.getWorkspaceCommandsForVersion( - currentVersionMajorMinor, - ); - const fromWorkspaceVersion = this.coreEngineVersionService.getPreviousVersion(); - const instanceCommands = - this.upgradeCommandRegistryService.getInstanceCommandsForVersion( + const { fastInstanceCommands, slowInstanceCommands, workspaceCommands } = + this.upgradeCommandRegistryService.getBundleForVersion( currentVersionMajorMinor, ); @@ -285,8 +264,9 @@ Please roll back to that version and run the upgrade command again.`, fromWorkspaceVersion, currentAppVersion, currentVersionMajorMinor, + fastInstanceCommands, + slowInstanceCommands, workspaceCommands, - instanceCommands, }; } @@ -308,7 +288,9 @@ Please roll back to that version and run the upgrade command again.`, options, fromWorkspaceVersion: versionContext.fromWorkspaceVersion, currentAppVersion: versionContext.currentAppVersion, - workspaceCommands: versionContext.workspaceCommands, + workspaceCommands: versionContext.workspaceCommands.map( + (entry) => entry.command, + ), }); }, }); diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator.ts b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator.ts index dc7ed58dd2..b54be481f0 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator.ts @@ -4,9 +4,12 @@ import { Injectable } from '@nestjs/common'; import { type UpgradeCommandVersion } from 'src/engine/constants/upgrade-command-supported-versions.constant'; +export type InstanceCommandType = 'fast' | 'slow'; + export type RegisteredInstanceCommandMetadata = { version: UpgradeCommandVersion; timestamp: number; + type: InstanceCommandType; }; const REGISTERED_INSTANCE_COMMAND_KEY = 'REGISTERED_INSTANCE_COMMAND'; @@ -15,12 +18,16 @@ const REGISTERED_INSTANCE_COMMAND_KEY = 'REGISTERED_INSTANCE_COMMAND'; // remove the @RegisteredInstanceCommand decorator from its associated // command files. export const RegisteredInstanceCommand = - (version: UpgradeCommandVersion, timestamp: number): ClassDecorator => + ( + version: UpgradeCommandVersion, + timestamp: number, + options?: { type: 'slow' }, + ): ClassDecorator => (target) => { Injectable()(target); Reflect.defineMetadata( REGISTERED_INSTANCE_COMMAND_KEY, - { version, timestamp }, + { version, timestamp, type: options?.type ?? 'fast' }, target, ); }; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface.ts b/packages/twenty-server/src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface.ts new file mode 100644 index 0000000000..3bd624100c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface.ts @@ -0,0 +1,6 @@ +import { type QueryRunner } from 'typeorm'; + +export interface FastInstanceCommand { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +} diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface.ts b/packages/twenty-server/src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface.ts new file mode 100644 index 0000000000..0839ba9345 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface.ts @@ -0,0 +1,7 @@ +import { type DataSource } from 'typeorm'; + +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +export interface SlowInstanceCommand extends FastInstanceCommand { + runDataMigration(dataSource: DataSource): Promise; +} diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-command-registry.service.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-command-registry.service.spec.ts index 3836e04324..b0ff1fec58 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-command-registry.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/upgrade-command-registry.service.spec.ts @@ -3,14 +3,17 @@ import 'reflect-metadata'; import { Test } from '@nestjs/testing'; import { DiscoveryService } from '@nestjs/core'; -import { type MigrationInterface } from 'typeorm'; +import { type DataSource } from 'typeorm'; + +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/services/upgrade-command-registry.service'; import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { type SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; @RegisteredInstanceCommand('1.21.0', 1770000000000) -class MigrationA1770000000000 implements MigrationInterface { +class MigrationA1770000000000 implements FastInstanceCommand { name = 'MigrationA1770000000000'; async up(): Promise {} @@ -18,7 +21,7 @@ class MigrationA1770000000000 implements MigrationInterface { } @RegisteredInstanceCommand('1.21.0', 1771000000000) -class MigrationB1771000000000 implements MigrationInterface { +class MigrationB1771000000000 implements FastInstanceCommand { name = 'MigrationB1771000000000'; async up(): Promise {} @@ -26,7 +29,7 @@ class MigrationB1771000000000 implements MigrationInterface { } @RegisteredInstanceCommand('1.21.0', 1772000000000) -class MigrationC1772000000000 implements MigrationInterface { +class MigrationC1772000000000 implements FastInstanceCommand { name = 'MigrationC1772000000000'; async up(): Promise {} @@ -34,14 +37,14 @@ class MigrationC1772000000000 implements MigrationInterface { } @RegisteredInstanceCommand('1.20.0', 1769000000000) -class MigrationD1769000000000 implements MigrationInterface { +class MigrationD1769000000000 implements FastInstanceCommand { name = 'MigrationD1769000000000'; async up(): Promise {} async down(): Promise {} } -class UndecoratedMigration1768000000000 implements MigrationInterface { +class UndecoratedMigration1768000000000 implements FastInstanceCommand { name = 'UndecoratedMigration1768000000000'; async up(): Promise {} @@ -94,14 +97,16 @@ describe('UpgradeCommandRegistryService', () => { new MigrationC1772000000000(), ]); - const v120 = service.getInstanceCommandsForVersion('1.20.0'); - const v121 = service.getInstanceCommandsForVersion('1.21.0'); + const v120 = service.getBundleForVersion('1.20.0'); + const v121 = service.getBundleForVersion('1.21.0'); - expect(v120.map((migration) => migration.constructor.name)).toStrictEqual([ - 'MigrationD1769000000000', - ]); + expect( + v120.fastInstanceCommands.map((entry) => entry.command.constructor.name), + ).toStrictEqual(['MigrationD1769000000000']); - expect(v121.map((migration) => migration.constructor.name)).toStrictEqual([ + expect( + v121.fastInstanceCommands.map((entry) => entry.command.constructor.name), + ).toStrictEqual([ 'MigrationA1770000000000', 'MigrationB1771000000000', 'MigrationC1772000000000', @@ -116,8 +121,8 @@ describe('UpgradeCommandRegistryService', () => { ]); const names = service - .getInstanceCommandsForVersion('1.21.0') - .map((migration) => migration.constructor.name); + .getBundleForVersion('1.21.0') + .fastInstanceCommands.map((entry) => entry.command.constructor.name); expect(names).toStrictEqual([ 'MigrationA1770000000000', @@ -132,26 +137,32 @@ describe('UpgradeCommandRegistryService', () => { new MigrationA1770000000000(), ]); - const v121 = service.getInstanceCommandsForVersion('1.21.0'); + const v121 = service.getBundleForVersion('1.21.0'); - expect(v121).toHaveLength(1); - expect(v121[0].constructor.name).toBe('MigrationA1770000000000'); + expect(v121.fastInstanceCommands).toHaveLength(1); + expect(v121.fastInstanceCommands[0].command.constructor.name).toBe( + 'MigrationA1770000000000', + ); }); it('should return empty array for version with no commands', async () => { const service = await buildRegistryService([]); - expect(service.getInstanceCommandsForVersion('1.20.0')).toStrictEqual([]); - expect(service.getInstanceCommandsForVersion('1.21.0')).toStrictEqual([]); - expect(service.getWorkspaceCommandsForVersion('1.20.0')).toStrictEqual([]); - expect(service.getWorkspaceCommandsForVersion('1.21.0')).toStrictEqual([]); + const v120 = service.getBundleForVersion('1.20.0'); + const v121 = service.getBundleForVersion('1.21.0'); + + expect(v120.fastInstanceCommands).toStrictEqual([]); + expect(v121.fastInstanceCommands).toStrictEqual([]); + expect(v120.workspaceCommands).toStrictEqual([]); + expect(v121.workspaceCommands).toStrictEqual([]); }); it('should return empty array for unsupported version', async () => { const service = await buildRegistryService([]); expect( - service.getInstanceCommandsForVersion('99.0.0' as unknown as '1.21.0'), + service.getBundleForVersion('99.0.0' as unknown as '1.21.0') + .fastInstanceCommands, ).toStrictEqual([]); }); @@ -161,12 +172,11 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandA(), ]); - const commands = service.getWorkspaceCommandsForVersion('1.21.0'); + const { workspaceCommands } = service.getBundleForVersion('1.21.0'); - expect(commands.map((command) => command.constructor.name)).toStrictEqual([ - 'WorkspaceCommandA', - 'WorkspaceCommandB', - ]); + expect( + workspaceCommands.map((entry) => entry.command.constructor.name), + ).toStrictEqual(['WorkspaceCommandA', 'WorkspaceCommandB']); }); it('should discover both instance and workspace commands for the same version', async () => { @@ -177,11 +187,10 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandB(), ]); - const instanceCommands = service.getInstanceCommandsForVersion('1.21.0'); - const workspaceCommands = service.getWorkspaceCommandsForVersion('1.21.0'); + const bucket = service.getBundleForVersion('1.21.0'); - expect(instanceCommands).toHaveLength(2); - expect(workspaceCommands).toHaveLength(2); + expect(bucket.fastInstanceCommands).toHaveLength(2); + expect(bucket.workspaceCommands).toHaveLength(2); }); it('should allow same timestamp across different kinds', async () => { @@ -195,13 +204,15 @@ describe('UpgradeCommandRegistryService', () => { new WorkspaceCommandSameTimestamp(), ]); - expect(service.getInstanceCommandsForVersion('1.21.0')).toHaveLength(1); - expect(service.getWorkspaceCommandsForVersion('1.21.0')).toHaveLength(1); + const bucket = service.getBundleForVersion('1.21.0'); + + expect(bucket.fastInstanceCommands).toHaveLength(1); + expect(bucket.workspaceCommands).toHaveLength(1); }); it('should throw on duplicate timestamps within the same kind', async () => { @RegisteredInstanceCommand('1.21.0', 1770000000000) - class DuplicateInstanceTimestamp implements MigrationInterface { + class DuplicateInstanceTimestamp implements FastInstanceCommand { name = 'DuplicateInstanceTimestamp'; async up(): Promise {} @@ -213,7 +224,9 @@ describe('UpgradeCommandRegistryService', () => { new MigrationA1770000000000(), new DuplicateInstanceTimestamp(), ]), - ).rejects.toThrow('Duplicate instance command timestamp 1770000000000'); + ).rejects.toThrow( + 'Duplicate fast-instance command timestamp 1770000000000', + ); }); it('should throw on duplicate computed names across kinds', async () => { @@ -244,32 +257,20 @@ describe('UpgradeCommandRegistryService', () => { new MigrationB1771000000000(), ]); - const allCommands = service.getAllInstanceCommands(); + const allCommands = service.getAllFastInstanceCommands(); - expect(allCommands).toStrictEqual([ - { - version: '1.20.0', - migration: expect.objectContaining({ name: 'MigrationD1769000000000' }), - }, - { - version: '1.21.0', - migration: expect.objectContaining({ name: 'MigrationA1770000000000' }), - }, - { - version: '1.21.0', - migration: expect.objectContaining({ name: 'MigrationB1771000000000' }), - }, - { - version: '1.21.0', - migration: expect.objectContaining({ name: 'MigrationC1772000000000' }), - }, + expect(allCommands.map((entry) => entry.name)).toStrictEqual([ + '1.20.0_MigrationD1769000000000_1769000000000', + '1.21.0_MigrationA1770000000000_1770000000000', + '1.21.0_MigrationB1771000000000_1771000000000', + '1.21.0_MigrationC1772000000000_1772000000000', ]); }); - it('should return empty array from getAllInstanceCommands when no commands registered', async () => { + it('should return empty array from getAllFastInstanceCommands when no commands registered', async () => { const service = await buildRegistryService([]); - expect(service.getAllInstanceCommands()).toStrictEqual([]); + expect(service.getAllFastInstanceCommands()).toStrictEqual([]); }); it('should allow same class name with different timestamps across kinds', async () => { @@ -287,7 +288,146 @@ describe('UpgradeCommandRegistryService', () => { new MigrationA1770000000000_WS(), ]); - expect(service.getInstanceCommandsForVersion('1.21.0')).toHaveLength(1); - expect(service.getWorkspaceCommandsForVersion('1.21.0')).toHaveLength(1); + const bucket = service.getBundleForVersion('1.21.0'); + + expect(bucket.fastInstanceCommands).toHaveLength(1); + expect(bucket.workspaceCommands).toHaveLength(1); + }); + + it('should discover slow instance commands and sort by timestamp', async () => { + @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + class SlowMigrationB1780000000000 implements SlowInstanceCommand { + name = 'SlowMigrationB1780000000000'; + + async runDataMigration(_dataSource: DataSource): Promise {} + async up(): Promise {} + async down(): Promise {} + } + + @RegisteredInstanceCommand('1.21.0', 1779000000000, { type: 'slow' }) + class SlowMigrationA1779000000000 implements SlowInstanceCommand { + name = 'SlowMigrationA1779000000000'; + + async runDataMigration(_dataSource: DataSource): Promise {} + async up(): Promise {} + async down(): Promise {} + } + + const service = await buildRegistryService([ + new SlowMigrationB1780000000000(), + new SlowMigrationA1779000000000(), + ]); + + const { slowInstanceCommands } = service.getBundleForVersion('1.21.0'); + + expect( + slowInstanceCommands.map((entry) => entry.command.constructor.name), + ).toStrictEqual([ + 'SlowMigrationA1779000000000', + 'SlowMigrationB1780000000000', + ]); + }); + + it('should separate fast and slow instance commands in the same version', async () => { + @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + class SlowMigration1780000000000 implements SlowInstanceCommand { + name = 'SlowMigration1780000000000'; + + async runDataMigration(_dataSource: DataSource): Promise {} + async up(): Promise {} + async down(): Promise {} + } + + const service = await buildRegistryService([ + new MigrationA1770000000000(), + new SlowMigration1780000000000(), + ]); + + const bucket = service.getBundleForVersion('1.21.0'); + + expect(bucket.fastInstanceCommands).toHaveLength(1); + expect(bucket.slowInstanceCommands).toHaveLength(1); + }); + + it('should throw on duplicate timestamps within slow instance commands', async () => { + @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + class SlowMigrationA1780000000000 implements SlowInstanceCommand { + name = 'SlowMigrationA1780000000000'; + + async runDataMigration(_dataSource: DataSource): Promise {} + async up(): Promise {} + async down(): Promise {} + } + + @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + class SlowMigrationB1780000000000 implements SlowInstanceCommand { + name = 'SlowMigrationB1780000000000'; + + async runDataMigration(_dataSource: DataSource): Promise {} + async up(): Promise {} + async down(): Promise {} + } + + await expect( + buildRegistryService([ + new SlowMigrationA1780000000000(), + new SlowMigrationB1780000000000(), + ]), + ).rejects.toThrow( + 'Duplicate slow-instance command timestamp 1780000000000', + ); + }); + + it('should allow same timestamp across fast and slow instance commands', async () => { + @RegisteredInstanceCommand('1.21.0', 1770000000000, { type: 'slow' }) + class SlowMigrationSameTimestamp implements SlowInstanceCommand { + name = 'SlowMigrationSameTimestamp'; + + async runDataMigration(_dataSource: DataSource): Promise {} + async up(): Promise {} + async down(): Promise {} + } + + const service = await buildRegistryService([ + new MigrationA1770000000000(), + new SlowMigrationSameTimestamp(), + ]); + + const bucket = service.getBundleForVersion('1.21.0'); + + expect(bucket.fastInstanceCommands).toHaveLength(1); + expect(bucket.slowInstanceCommands).toHaveLength(1); + }); + + it('should return all slow instance commands across versions', async () => { + @RegisteredInstanceCommand('1.21.0', 1780000000000, { type: 'slow' }) + class SlowMigration1780000000000 implements SlowInstanceCommand { + name = 'SlowMigration1780000000000'; + + async runDataMigration(_dataSource: DataSource): Promise {} + async up(): Promise {} + async down(): Promise {} + } + + @RegisteredInstanceCommand('1.20.0', 1768000000000, { type: 'slow' }) + class SlowMigration1768000000000 implements SlowInstanceCommand { + name = 'SlowMigration1768000000000'; + + async runDataMigration(_dataSource: DataSource): Promise {} + async up(): Promise {} + async down(): Promise {} + } + + const service = await buildRegistryService([ + new SlowMigration1780000000000(), + new SlowMigration1768000000000(), + ]); + + const allSlowCommands = service.getAllSlowInstanceCommands(); + + expect(allSlowCommands.map((entry) => entry.name)).toStrictEqual([ + '1.20.0_SlowMigration1768000000000_1768000000000', + '1.21.0_SlowMigration1780000000000_1780000000000', + ]); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/instance-upgrade.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/instance-upgrade.service.ts index a5dfcdd16c..5260167730 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/instance-upgrade.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/instance-upgrade.service.ts @@ -1,18 +1,22 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, MigrationInterface } from 'typeorm'; +import { DataSource } from 'typeorm'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; +import { type SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; -export type RunSingleMigrationResult = +type RunSingleMigrationResult = | { status: 'success' } | { status: 'already-executed' } | { status: 'failed'; error: unknown }; @Injectable() export class InstanceUpgradeService { + private readonly logger = new Logger(InstanceUpgradeService.name); + constructor( @InjectDataSource() private readonly dataSource: DataSource, @@ -20,20 +24,25 @@ export class InstanceUpgradeService { private readonly upgradeMigrationService: UpgradeMigrationService, ) {} - async runSingleMigration( - migration: MigrationInterface, - ): Promise { - const migrationName = migration.constructor.name; + async runFastInstanceCommand({ + command, + name, + }: { + command: FastInstanceCommand; + name: string; + }): Promise { const executedByVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown'; const isAlreadyCompleted = await this.upgradeMigrationService.isLastAttemptCompleted({ - name: migrationName, + name, workspaceId: null, }); if (isAlreadyCompleted) { + this.logger.log(`${name} already executed, skipping`); + return { status: 'already-executed' }; } @@ -43,10 +52,10 @@ export class InstanceUpgradeService { await queryRunner.connect(); await queryRunner.startTransaction(); - await migration.up(queryRunner); + await command.up(queryRunner); await this.upgradeMigrationService.markAsCompleted({ - name: migrationName, + name, workspaceId: null, executedByVersion, queryRunner, @@ -59,16 +68,69 @@ export class InstanceUpgradeService { } await this.upgradeMigrationService.markAsFailed({ - name: migrationName, + name, workspaceId: null, executedByVersion, }); + this.logger.error( + `${name} failed`, + error instanceof Error ? error.stack : String(error), + ); + return { status: 'failed', error }; } finally { await queryRunner.release(); } + this.logger.log(`${name} executed successfully`); + return { status: 'success' }; } + + async runSlowInstanceCommand({ + command, + name, + skipDataMigration, + }: { + command: SlowInstanceCommand; + name: string; + skipDataMigration?: boolean; + }): Promise { + const isAlreadyCompleted = + await this.upgradeMigrationService.isLastAttemptCompleted({ + name, + workspaceId: null, + }); + + if (isAlreadyCompleted) { + this.logger.log(`${name} already executed, skipping`); + + return { status: 'already-executed' }; + } + + if (!skipDataMigration) { + const executedByVersion = + this.twentyConfigService.get('APP_VERSION') ?? 'unknown'; + + try { + await command.runDataMigration(this.dataSource); + } catch (error) { + await this.upgradeMigrationService.markAsFailed({ + name, + workspaceId: null, + executedByVersion, + }); + + this.logger.error( + `${name} data migration failed`, + error instanceof Error ? error.stack : String(error), + ); + + return { status: 'failed', error }; + } + } + + return this.runFastInstanceCommand({ command, name }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-command-registry.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-command-registry.service.ts index fb10727367..d768ec5fd2 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-command-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-command-registry.service.ts @@ -1,12 +1,12 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; import { DiscoveryService } from '@nestjs/core'; -import { type MigrationInterface } from 'typeorm'; - import { type ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; import { type WorkspaceCommandRunner } from 'src/database/commands/command-runners/workspace.command-runner'; -import { getRegisteredWorkspaceCommandMetadata } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; import { getRegisteredInstanceCommandMetadata } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { getRegisteredWorkspaceCommandMetadata } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; +import { type SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; import { UPGRADE_COMMAND_SUPPORTED_VERSIONS, type UpgradeCommandVersion, @@ -17,38 +17,52 @@ type WorkspaceCommand = | WorkspaceCommandRunner | ActiveOrSuspendedWorkspaceCommandRunner; -type RegisteredInstanceCommand = { +export type RegisteredFastInstanceCommand = { name: string; - command: MigrationInterface; + command: FastInstanceCommand; timestamp: number; }; -type RegisteredWorkspaceCommand = { +export type RegisteredSlowInstanceCommand = { + name: string; + command: SlowInstanceCommand; + timestamp: number; +}; + +export type RegisteredWorkspaceCommand = { name: string; command: WorkspaceCommand; timestamp: number; }; -type VersionBucket = { - instanceCommands: RegisteredInstanceCommand[]; +export type VersionBundle = { + fastInstanceCommands: RegisteredFastInstanceCommand[]; + slowInstanceCommands: RegisteredSlowInstanceCommand[]; workspaceCommands: RegisteredWorkspaceCommand[]; }; +const buildEmptyVersionBundle = (): VersionBundle => ({ + fastInstanceCommands: [], + slowInstanceCommands: [], + workspaceCommands: [], +}); + @Injectable() export class UpgradeCommandRegistryService implements OnModuleInit { private readonly logger = new Logger(UpgradeCommandRegistryService.name); - private readonly bucketsByVersion = new Map< + private readonly bundlesByVersion = new Map< UpgradeCommandVersion, - VersionBucket + VersionBundle >(); constructor(private readonly discoveryService: DiscoveryService) {} onModuleInit(): void { for (const version of UPGRADE_COMMAND_SUPPORTED_VERSIONS) { - this.bucketsByVersion.set(version, { - instanceCommands: [], + this.bundlesByVersion.set(version, { + fastInstanceCommands: [], + slowInstanceCommands: [], workspaceCommands: [], }); } @@ -66,20 +80,31 @@ export class UpgradeCommandRegistryService implements OnModuleInit { getRegisteredInstanceCommandMetadata(metatype); if (isDefined(instanceCommandMetadata)) { - const bucket = this.bucketsByVersion.get( + const bundle = this.bundlesByVersion.get( instanceCommandMetadata.version, ); - if (isDefined(bucket)) { - bucket.instanceCommands.push({ + if (isDefined(bundle)) { + const entry = { name: this.computeCommandName( instanceCommandMetadata.version, - (instance as MigrationInterface).constructor.name, + (instance as FastInstanceCommand).constructor.name, instanceCommandMetadata.timestamp, ), - command: instance as MigrationInterface, timestamp: instanceCommandMetadata.timestamp, - }); + }; + + if (instanceCommandMetadata.type === 'slow') { + bundle.slowInstanceCommands.push({ + ...entry, + command: instance as SlowInstanceCommand, + }); + } else { + bundle.fastInstanceCommands.push({ + ...entry, + command: instance as FastInstanceCommand, + }); + } } continue; @@ -89,12 +114,12 @@ export class UpgradeCommandRegistryService implements OnModuleInit { getRegisteredWorkspaceCommandMetadata(metatype); if (isDefined(workspaceCommandMetadata)) { - const bucket = this.bucketsByVersion.get( + const bundle = this.bundlesByVersion.get( workspaceCommandMetadata.version, ); - if (isDefined(bucket)) { - bucket.workspaceCommands.push({ + if (isDefined(bundle)) { + bundle.workspaceCommands.push({ name: this.computeCommandName( workspaceCommandMetadata.version, (instance as WorkspaceCommand).constructor.name, @@ -107,67 +132,50 @@ export class UpgradeCommandRegistryService implements OnModuleInit { } } - for (const [, bucket] of this.bucketsByVersion) { - bucket.instanceCommands.sort( + for (const [, bundle] of this.bundlesByVersion) { + bundle.fastInstanceCommands.sort( (entryA, entryB) => entryA.timestamp - entryB.timestamp, ); - bucket.workspaceCommands.sort( + bundle.slowInstanceCommands.sort( + (entryA, entryB) => entryA.timestamp - entryB.timestamp, + ); + bundle.workspaceCommands.sort( (entryA, entryB) => entryA.timestamp - entryB.timestamp, ); } this.validateNoDuplicates(); - for (const [version, bucket] of this.bucketsByVersion) { + for (const [version, bundle] of this.bundlesByVersion) { const totalCount = - bucket.instanceCommands.length + bucket.workspaceCommands.length; + bundle.fastInstanceCommands.length + + bundle.slowInstanceCommands.length + + bundle.workspaceCommands.length; if (totalCount > 0) { this.logger.log( - `Registered ${bucket.instanceCommands.length} instance command(s) and ${bucket.workspaceCommands.length} workspace command(s) for ${version}`, + `Registered ${bundle.fastInstanceCommands.length} fast instance, ${bundle.slowInstanceCommands.length} slow instance, and ${bundle.workspaceCommands.length} workspace command(s) for ${version}`, ); } } } - getInstanceCommandsForVersion( - version: UpgradeCommandVersion, - ): MigrationInterface[] { - return ( - this.bucketsByVersion - .get(version) - ?.instanceCommands.map((entry) => entry.command) ?? [] + getBundleForVersion(version: UpgradeCommandVersion): VersionBundle { + return this.bundlesByVersion.get(version) ?? buildEmptyVersionBundle(); + } + + getAllFastInstanceCommands(): RegisteredFastInstanceCommand[] { + return UPGRADE_COMMAND_SUPPORTED_VERSIONS.flatMap( + (version) => this.getBundleForVersion(version).fastInstanceCommands, ); } - getWorkspaceCommandsForVersion( - version: UpgradeCommandVersion, - ): WorkspaceCommand[] { - return ( - this.bucketsByVersion - .get(version) - ?.workspaceCommands.map((entry) => entry.command) ?? [] + getAllSlowInstanceCommands(): RegisteredSlowInstanceCommand[] { + return UPGRADE_COMMAND_SUPPORTED_VERSIONS.flatMap( + (version) => this.getBundleForVersion(version).slowInstanceCommands, ); } - getAllInstanceCommands(): { - version: UpgradeCommandVersion; - migration: MigrationInterface; - }[] { - const result: { - version: UpgradeCommandVersion; - migration: MigrationInterface; - }[] = []; - - for (const version of UPGRADE_COMMAND_SUPPORTED_VERSIONS) { - for (const command of this.getInstanceCommandsForVersion(version)) { - result.push({ version, migration: command }); - } - } - - return result; - } - private computeCommandName( version: UpgradeCommandVersion, className: string, @@ -177,23 +185,29 @@ export class UpgradeCommandRegistryService implements OnModuleInit { } private validateNoDuplicates(): void { - for (const [version, bucket] of this.bucketsByVersion) { + for (const [version, bundle] of this.bundlesByVersion) { this.validateNoTimestampDuplicatesWithinKind( version, - 'instance', - bucket.instanceCommands, + 'fast-instance', + bundle.fastInstanceCommands, + ); + this.validateNoTimestampDuplicatesWithinKind( + version, + 'slow-instance', + bundle.slowInstanceCommands, ); this.validateNoTimestampDuplicatesWithinKind( version, 'workspace', - bucket.workspaceCommands, + bundle.workspaceCommands, ); const seenNames = new Set(); const allNames = [ - ...bucket.instanceCommands.map((entry) => entry.name), - ...bucket.workspaceCommands.map((entry) => entry.name), + ...bundle.fastInstanceCommands.map((entry) => entry.name), + ...bundle.slowInstanceCommands.map((entry) => entry.name), + ...bundle.workspaceCommands.map((entry) => entry.name), ]; for (const name of allNames) { @@ -210,8 +224,11 @@ export class UpgradeCommandRegistryService implements OnModuleInit { private validateNoTimestampDuplicatesWithinKind( version: UpgradeCommandVersion, - kind: 'instance' | 'workspace', - entries: RegisteredInstanceCommand[] | RegisteredWorkspaceCommand[], + kind: 'fast-instance' | 'slow-instance' | 'workspace', + entries: + | RegisteredFastInstanceCommand[] + | RegisteredSlowInstanceCommand[] + | RegisteredWorkspaceCommand[], ): void { const seenTimestamps = new Set();