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();