diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1775553825848-add-workspace-id-to-upgrade-migration.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1775553825848-add-workspace-id-to-upgrade-migration.ts new file mode 100644 index 0000000000..ebd68d0958 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1775553825848-add-workspace-id-to-upgrade-migration.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWorkspaceIdToUpgradeMigration1775553825848 + implements MigrationInterface +{ + name = 'AddWorkspaceIdToUpgradeMigration1775553825848'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" DROP CONSTRAINT "UQ_upgrade_migration_name_attempt"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" ADD "workspaceId" uuid', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "UQ_upgrade_migration_workspace" ON "core"."upgradeMigration" ("name", "attempt", "workspaceId") WHERE "workspaceId" IS NOT NULL', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "UQ_upgrade_migration_instance" ON "core"."upgradeMigration" ("name", "attempt") WHERE "workspaceId" IS NULL', + ); + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" ADD CONSTRAINT "FK_77f64a697c55f8802592bd7eeba" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" DROP CONSTRAINT "FK_77f64a697c55f8802592bd7eeba"', + ); + await queryRunner.query( + 'DROP INDEX "core"."UQ_upgrade_migration_instance"', + ); + await queryRunner.query( + 'DROP INDEX "core"."UQ_upgrade_migration_workspace"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" DROP COLUMN "workspaceId"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" ADD CONSTRAINT "UQ_upgrade_migration_name_attempt" UNIQUE ("name", "attempt")', + ); + } +} 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 e9b6709bae..a5dfcdd16c 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,10 @@ import { Injectable } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; -import { - DataSource, - MigrationInterface, - type QueryRunner, - Repository, -} from 'typeorm'; +import { DataSource, MigrationInterface } from 'typeorm'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { - UpgradeMigrationEntity, - type UpgradeMigrationStatus, -} from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; +import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; export type RunSingleMigrationResult = | { status: 'success' } @@ -22,11 +14,10 @@ export type RunSingleMigrationResult = @Injectable() export class InstanceUpgradeService { constructor( - @InjectRepository(UpgradeMigrationEntity) - private readonly upgradeMigrationRepository: Repository, @InjectDataSource() private readonly dataSource: DataSource, private readonly twentyConfigService: TwentyConfigService, + private readonly upgradeMigrationService: UpgradeMigrationService, ) {} async runSingleMigration( @@ -36,11 +27,13 @@ export class InstanceUpgradeService { const executedByVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown'; - const isAlreadyExecuted = await this.upgradeMigrationRepository.exists({ - where: { name: migrationName, status: 'completed' }, - }); + const isAlreadyCompleted = + await this.upgradeMigrationService.isLastAttemptCompleted({ + name: migrationName, + workspaceId: null, + }); - if (isAlreadyExecuted) { + if (isAlreadyCompleted) { return { status: 'already-executed' }; } @@ -52,10 +45,11 @@ export class InstanceUpgradeService { await migration.up(queryRunner); - await this.markAsCompleted({ - queryRunner, + await this.upgradeMigrationService.markAsCompleted({ name: migrationName, + workspaceId: null, executedByVersion, + queryRunner, }); await queryRunner.commitTransaction(); @@ -64,8 +58,9 @@ export class InstanceUpgradeService { await queryRunner.rollbackTransaction(); } - await this.markFailed({ + await this.upgradeMigrationService.markAsFailed({ name: migrationName, + workspaceId: null, executedByVersion, }); @@ -76,46 +71,4 @@ export class InstanceUpgradeService { return { status: 'success' }; } - - private async markAsCompleted({ - queryRunner, - name, - executedByVersion, - }: { - queryRunner: QueryRunner; - name: string; - executedByVersion: string; - }): Promise { - const repository = queryRunner.manager.getRepository( - UpgradeMigrationEntity, - ); - - const previousAttempts = await repository.count({ where: { name } }); - - await repository.save({ - name, - status: 'completed' as UpgradeMigrationStatus, - attempt: previousAttempts + 1, - executedByVersion, - }); - } - - private async markFailed({ - name, - executedByVersion, - }: { - name: string; - executedByVersion: string; - }): Promise { - const previousAttempts = await this.upgradeMigrationRepository.count({ - where: { name }, - }); - - await this.upgradeMigrationRepository.save({ - name, - status: 'failed' as UpgradeMigrationStatus, - attempt: previousAttempts + 1, - executedByVersion, - }); - } } diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts new file mode 100644 index 0000000000..2f5b22a0d5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, type QueryRunner, Repository } from 'typeorm'; + +import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; + +@Injectable() +export class UpgradeMigrationService { + constructor( + @InjectRepository(UpgradeMigrationEntity) + private readonly upgradeMigrationRepository: Repository, + ) {} + + async isLastAttemptCompleted({ + name, + workspaceId, + }: { + name: string; + workspaceId: string | null; + }): Promise { + const latestAttempt = await this.upgradeMigrationRepository.findOne({ + where: { + name, + workspaceId: workspaceId === null ? IsNull() : workspaceId, + }, + order: { attempt: 'DESC' }, + }); + + return isDefined(latestAttempt) && latestAttempt.status === 'completed'; + } + + async markAsCompleted({ + name, + workspaceId, + executedByVersion, + queryRunner, + }: { + name: string; + workspaceId: string | null; + executedByVersion: string; + queryRunner?: QueryRunner; + }): Promise { + const repository = queryRunner + ? queryRunner.manager.getRepository(UpgradeMigrationEntity) + : this.upgradeMigrationRepository; + const previousAttempts = await repository.count({ + where: { + name, + workspaceId: workspaceId === null ? IsNull() : workspaceId, + }, + }); + + await repository.save({ + name, + status: 'completed', + attempt: previousAttempts + 1, + executedByVersion, + workspaceId, + }); + } + + async markAsFailed({ + name, + workspaceId, + executedByVersion, + }: { + name: string; + workspaceId: string | null; + executedByVersion: string; + }): Promise { + const previousAttempts = await this.upgradeMigrationRepository.count({ + where: { + name, + workspaceId: workspaceId === null ? IsNull() : workspaceId, + }, + }); + + await this.upgradeMigrationRepository.save({ + name, + status: 'failed', + attempt: previousAttempts + 1, + executedByVersion, + workspaceId, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/workspace-upgrade.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/workspace-upgrade.service.ts index 7f385d6281..80828fa649 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/workspace-upgrade.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/workspace-upgrade.service.ts @@ -5,12 +5,13 @@ import { SemVer } from 'semver'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; -import { type WorkspaceIteratorContext } from 'src/database/commands/command-runners/workspace-iterator.service'; import { type UpgradeCommandOptions, type VersionCommands, } from 'src/database/commands/command-runners/upgrade.command-runner'; -import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { type WorkspaceIteratorContext } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { type CompareVersionMajorAndMinorReturnType, @@ -32,6 +33,8 @@ export class WorkspaceUpgradeService { constructor( @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, + private readonly twentyConfigService: TwentyConfigService, + private readonly upgradeMigrationService: UpgradeMigrationService, ) {} async upgradeWorkspace({ @@ -60,13 +63,16 @@ export class WorkspaceUpgradeService { ); } case 'equal': { + const executedByVersion = + this.twentyConfigService.get('APP_VERSION') ?? 'unknown'; + for (const workspaceCommand of workspaceCommands) { - await workspaceCommand.runOnWorkspace({ - options: options as RunOnWorkspaceArgs['options'], + await this.runSingleWorkspaceCommandOrThrow({ + workspaceCommand, workspaceId, - dataSource: iteratorContext.dataSource, - index, - total, + executedByVersion, + options, + iteratorContext, }); } @@ -112,4 +118,62 @@ export class WorkspaceUpgradeService { fromWorkspaceVersion.version, ); } + + private async runSingleWorkspaceCommandOrThrow({ + workspaceCommand, + workspaceId, + executedByVersion, + options, + iteratorContext, + }: { + workspaceCommand: VersionCommands[number]; + workspaceId: string; + executedByVersion: string; + options: UpgradeCommandOptions; + iteratorContext: WorkspaceIteratorContext; + }): Promise { + const commandName = workspaceCommand.constructor.name; + + const isAlreadyCompleted = + await this.upgradeMigrationService.isLastAttemptCompleted({ + name: commandName, + workspaceId, + }); + + if (isAlreadyCompleted) { + this.logger.log( + `Workspace command ${commandName} already completed for workspace ${workspaceId}, skipping`, + ); + + return; + } + + try { + await workspaceCommand.runOnWorkspace({ + options, + workspaceId, + dataSource: iteratorContext.dataSource, + index: iteratorContext.index, + total: iteratorContext.total, + }); + + if (!options.dryRun) { + await this.upgradeMigrationService.markAsCompleted({ + name: commandName, + workspaceId, + executedByVersion, + }); + } + } catch (error) { + if (!options.dryRun) { + await this.upgradeMigrationService.markAsFailed({ + name: commandName, + workspaceId, + executedByVersion, + }); + } + + throw error; + } + } } diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade-migration.entity.ts b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade-migration.entity.ts index f68669fdcf..e0391b2399 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade-migration.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade-migration.entity.ts @@ -2,14 +2,26 @@ import { Column, CreateDateColumn, Entity, + Index, + JoinColumn, + ManyToOne, PrimaryGeneratedColumn, - Unique, + type Relation, } from 'typeorm'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + export type UpgradeMigrationStatus = 'completed' | 'failed'; @Entity({ name: 'upgradeMigration', schema: 'core' }) -@Unique('UQ_upgrade_migration_name_attempt', ['name', 'attempt']) +@Index('UQ_upgrade_migration_instance', ['name', 'attempt'], { + unique: true, + where: '"workspaceId" IS NULL', +}) +@Index('UQ_upgrade_migration_workspace', ['name', 'attempt', 'workspaceId'], { + unique: true, + where: '"workspaceId" IS NOT NULL', +}) export class UpgradeMigrationEntity { @PrimaryGeneratedColumn('uuid') id: string; @@ -26,6 +38,13 @@ export class UpgradeMigrationEntity { @Column({ type: 'varchar', nullable: false }) executedByVersion: string; + @ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation | null; + + @Column({ type: 'uuid', nullable: true }) + workspaceId: string | null; + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; } diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts index 4b4fd8a439..6fab11052b 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { DiscoveryModule } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; import { InstanceUpgradeService } from 'src/engine/core-modules/upgrade/services/instance-upgrade.service'; import { RegisteredInstanceMigrationService } from 'src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service'; +import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; import { WorkspaceUpgradeService } from 'src/engine/core-modules/upgrade/services/workspace-upgrade.service'; +import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @Module({ @@ -14,11 +15,13 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent TypeOrmModule.forFeature([UpgradeMigrationEntity, WorkspaceEntity]), ], providers: [ + UpgradeMigrationService, InstanceUpgradeService, WorkspaceUpgradeService, RegisteredInstanceMigrationService, ], exports: [ + UpgradeMigrationService, InstanceUpgradeService, WorkspaceUpgradeService, RegisteredInstanceMigrationService,